diff --git a/charts/kagenti-operator/crds/agent.kagenti.dev_agentcards.yaml b/charts/kagenti-operator/crds/agent.kagenti.dev_agentcards.yaml index a47184bc..eaf6b743 100644 --- a/charts/kagenti-operator/crds/agent.kagenti.dev_agentcards.yaml +++ b/charts/kagenti-operator/crds/agent.kagenti.dev_agentcards.yaml @@ -81,51 +81,22 @@ spec: identityBinding: description: IdentityBinding specifies SPIFFE identity binding configuration properties: - allowedSpiffeIDs: - description: |- - AllowedSpiffeIDs is the allowlist of SPIFFE IDs that can bind to this agent. - Each ID must be a valid SPIFFE ID in the format spiffe:///. - The SPIFFE ID from the JWS protected header must match one of these entries. - items: - description: SpiffeID represents a SPIFFE identity in the format - spiffe:/// - pattern: ^spiffe://[a-zA-Z0-9][a-zA-Z0-9\-\.]*[a-zA-Z0-9](/[a-zA-Z0-9\-\._~%!$&'()*+,;=:@]+)*$ - type: string - minItems: 1 - type: array - expectedSpiffeID: - description: |- - Deprecated: ExpectedSpiffeID is no longer used. The SPIFFE ID now comes exclusively - from the JWS protected header (sign with --spiffe-id). This ensures all identity - claims are cryptographically bound to the signature. - This field is retained for backward compatibility and will be removed in a future release. - pattern: ^spiffe://[a-zA-Z0-9][a-zA-Z0-9\-\.]*[a-zA-Z0-9](/[a-zA-Z0-9\-\._~%!$&'()*+,;=:@]+)*$ - type: string strict: default: false description: |- - Strict enables strict enforcement mode for identity binding. - When true, binding failures result in network isolation: the signature-verified - label is removed from pods, and NetworkPolicy restricts all ingress/egress. - When false (audit mode), binding results are recorded in status but network - access is not affected. - NOTE: Scale-to-zero enforcement is only available via the legacy Agent CRD controller. + Strict enables enforcement mode: binding failures trigger network isolation. + When false (default), results are recorded in status only (audit mode). type: boolean trustDomain: description: |- - Deprecated: TrustDomain is no longer used. The trust domain is determined - from the SPIFFE ID in the JWS protected header. - This field is retained for backward compatibility and will be removed in a future release. + TrustDomain overrides the operator-level --spire-trust-domain for this AgentCard. + If empty, the operator flag value is used. pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$ type: string - required: - - allowedSpiffeIDs type: object selector: - description: |- - Selector identifies the Agent to index using label matching. - Deprecated: Use TargetRef instead. Selector is kept for backward compatibility. - If both TargetRef and Selector are specified, TargetRef takes precedence. + description: 'Deprecated: Use TargetRef instead. If both are set, + TargetRef takes precedence.' properties: matchLabels: additionalProperties: @@ -143,9 +114,8 @@ spec: type: string targetRef: description: |- - TargetRef identifies the workload backing this agent using duck typing. - The referenced workload must have the required Kagenti labels (kagenti.io/type=agent). - This is the preferred way to reference agent workloads. + TargetRef identifies the workload backing this agent (duck typing). + The workload must have the kagenti.io/type=agent label. properties: apiVersion: description: APIVersion is the API version of the target resource @@ -175,7 +145,7 @@ spec: evaluation properties: bound: - description: Bound indicates whether the expected SPIFFE ID is + description: Bound indicates whether the verified SPIFFE ID is in the allowlist type: boolean lastEvaluationTime: @@ -228,14 +198,10 @@ spec: description: Name is the human-readable name of the agent type: string signatures: - description: |- - Signatures contains JWS signatures per A2A spec section 8.4.2. - Each element uses JWS JSON Serialization with protected header containing - the algorithm (alg), key ID (kid), and optional SPIFFE ID (spiffe_id). + description: Signatures contains JWS signatures per A2A spec §8.4.2. items: - description: |- - AgentCardSignature represents a JWS signature on an AgentCard. - Follows the A2A specification section 8.4.2 — JWS JSON Serialization. + description: AgentCardSignature represents a JWS signature on + an AgentCard (A2A spec §8.4.2). properties: header: description: Header contains optional unprotected JWS header @@ -247,14 +213,12 @@ spec: type: string type: object protected: - description: |- - Protected is the base64url-encoded JWS protected header. - Decoded, it contains {"alg":"RS256","kid":"key-1","spiffe_id":"spiffe://..."}. + description: Protected is the base64url-encoded JWS protected + header (contains alg, kid, spiffe_id). type: string signature: - description: |- - Signature is the base64url-encoded JWS signature value. - The signing input is: BASE64URL(protected) || '.' || BASE64URL(canonical_payload) + description: Signature is the base64url-encoded JWS signature + value. type: string required: - protected @@ -401,18 +365,16 @@ spec: description: Protocol is the detected agent protocol (e.g., "a2a") type: string signatureIdentityMatch: - description: |- - SignatureIdentityMatch indicates if both signature AND identity binding pass. - true only when ValidSignature is true AND BindingStatus.Bound is true. + description: SignatureIdentityMatch is true when both signature and + identity binding pass. type: boolean signatureKeyId: description: SignatureKeyID is the key ID used for verification (from JWS protected header kid) type: string signatureSpiffeId: - description: |- - SignatureSpiffeID is the SPIFFE ID extracted from the JWS protected header. - This enables cross-referencing the signer's identity with the identity binding evaluation. + description: SignatureSpiffeID is the SPIFFE ID from the JWS protected + header (set only when valid). type: string signatureVerificationDetails: description: SignatureVerificationDetails contains details about the diff --git a/charts/kagenti-operator/templates/manager/manager.yaml b/charts/kagenti-operator/templates/manager/manager.yaml index 3d740802..67691812 100644 --- a/charts/kagenti-operator/templates/manager/manager.yaml +++ b/charts/kagenti-operator/templates/manager/manager.yaml @@ -33,24 +33,29 @@ spec: {{- end }} {{- if .Values.signatureVerification.enabled }} - "--require-a2a-signature=true" - - "--signature-provider={{ .Values.signatureVerification.provider }}" {{- if .Values.signatureVerification.auditMode }} - "--signature-audit-mode=true" {{- end }} - {{- if .Values.signatureVerification.secret.name }} - - "--signature-secret-name={{ .Values.signatureVerification.secret.name }}" + {{- if .Values.signatureVerification.enforceNetworkPolicies }} + - "--enforce-network-policies=true" {{- end }} - {{- if .Values.signatureVerification.secret.namespace }} - - "--signature-secret-namespace={{ .Values.signatureVerification.secret.namespace }}" + {{- if .Values.signatureVerification.spireTrustDomain }} + - "--spire-trust-domain={{ .Values.signatureVerification.spireTrustDomain }}" {{- end }} - {{- if .Values.signatureVerification.secret.key }} - - "--signature-secret-key={{ .Values.signatureVerification.secret.key }}" + {{- if .Values.signatureVerification.spireTrustBundle.configMapName }} + - "--spire-trust-bundle-configmap={{ .Values.signatureVerification.spireTrustBundle.configMapName }}" {{- end }} - {{- if .Values.signatureVerification.jwks.url }} - - "--signature-jwks-url={{ .Values.signatureVerification.jwks.url }}" + {{- if .Values.signatureVerification.spireTrustBundle.configMapNamespace }} + - "--spire-trust-bundle-configmap-namespace={{ .Values.signatureVerification.spireTrustBundle.configMapNamespace }}" {{- end }} - {{- if .Values.signatureVerification.enforceNetworkPolicies }} - - "--enforce-network-policies=true" + {{- if .Values.signatureVerification.spireTrustBundle.configMapKey }} + - "--spire-trust-bundle-configmap-key={{ .Values.signatureVerification.spireTrustBundle.configMapKey }}" + {{- end }} + {{- if .Values.signatureVerification.spireTrustBundle.refreshInterval }} + - "--spire-trust-bundle-refresh-interval={{ .Values.signatureVerification.spireTrustBundle.refreshInterval }}" + {{- end }} + {{- if .Values.signatureVerification.svidExpiryGracePeriod }} + - "--svid-expiry-grace-period={{ .Values.signatureVerification.svidExpiryGracePeriod }}" {{- end }} {{- end }} command: diff --git a/charts/kagenti-operator/values.yaml b/charts/kagenti-operator/values.yaml index b00caf55..b5e01f05 100644 --- a/charts/kagenti-operator/values.yaml +++ b/charts/kagenti-operator/values.yaml @@ -84,21 +84,21 @@ certmanager: networkPolicy: enable: false -# [SIGNATURE VERIFICATION]: A2A agent card signature verification +# [SIGNATURE VERIFICATION]: A2A agent card signature verification via SPIRE x5c signatureVerification: # Enable signature verification for agent cards enabled: false # Audit mode: log failures but don't block (use for gradual rollout) auditMode: false - # Provider type: "secret", "jwks", or "none" - provider: "none" - # Secret provider configuration - secret: - name: "" - namespace: "" - key: "" - # JWKS provider configuration - jwks: - url: "" # Enforce network policies based on signature verification enforceNetworkPolicies: false + # SPIRE trust domain (required when enabled) + spireTrustDomain: "" + # SPIRE trust bundle ConfigMap (SPIFFE JSON format from BundlePublisher) + spireTrustBundle: + configMapName: "" + configMapNamespace: "" + configMapKey: "bundle.spiffe" + refreshInterval: "5m" + # How far before SVID expiry to trigger proactive workload restart + svidExpiryGracePeriod: "30m" diff --git a/kagenti-operator/Dockerfile b/kagenti-operator/Dockerfile index 348b8372..4ea148ae 100644 --- a/kagenti-operator/Dockerfile +++ b/kagenti-operator/Dockerfile @@ -1,5 +1,5 @@ # Build the manager binary -FROM docker.io/golang:1.23 AS builder +FROM docker.io/golang:1.24 AS builder ARG TARGETOS ARG TARGETARCH diff --git a/kagenti-operator/GETTING_STARTED.md b/kagenti-operator/GETTING_STARTED.md index 3ee85ea8..01e01387 100644 --- a/kagenti-operator/GETTING_STARTED.md +++ b/kagenti-operator/GETTING_STARTED.md @@ -412,8 +412,8 @@ kubectl run curl-test --image=curlimages/curl:8.1.2 --rm -i --tty -n team1 -- \ ## Next Steps - [Dynamic Agent Discovery](docs/dynamic-agent-discovery.md) — How AgentCard enables agent discovery -- [Signature Verification](docs/a2a-signature-verification.md) — Set up JWS signature verification -- [Identity Binding](docs/identity-binding-quickstart.md) — Configure SPIFFE identity binding +- [Signature Verification](docs/agentcard-signature-verification.md) — Set up JWS signature verification +- [Identity Binding](docs/agentcard-identity-binding.md) — Configure SPIFFE identity binding - [Migration Guide](../docs/migration/migrate-agent-crd-to-workloads.md) — Migrating from Agent CRD to workloads - [API Reference](docs/api-reference.md) — Full CRD specifications diff --git a/kagenti-operator/Makefile b/kagenti-operator/Makefile index 7fb2ea01..659f48a0 100644 --- a/kagenti-operator/Makefile +++ b/kagenti-operator/Makefile @@ -150,6 +150,17 @@ build-installer: manifests generate kustomize ## Generate a consolidated YAML wi cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} $(KUSTOMIZE) build config/default > dist/install.yaml +SIGNER_IMG ?= kagenti/agentcard-signer:latest +KIND_CLUSTER_NAME ?= $(CLUSTER) + +.PHONY: build-signer +build-signer: ## Build the agentcard-signer init-container image. + $(CONTAINER_TOOL) build -t $(SIGNER_IMG) -f cmd/agentcard-signer/Dockerfile . + +.PHONY: load-signer-image +load-signer-image: ## Load the agentcard-signer image into Kind. + kind load docker-image $(SIGNER_IMG) --name $(KIND_CLUSTER_NAME) + ##@ Deployment ifndef ignore-not-found diff --git a/kagenti-operator/PROJECT b/kagenti-operator/PROJECT index c06a9a5e..387b5a70 100644 --- a/kagenti-operator/PROJECT +++ b/kagenti-operator/PROJECT @@ -17,17 +17,4 @@ resources: kind: Agent path: github.com/kagenti/operator/api/v1alpha1 version: v1alpha1 -- api: - crdVersion: v1 - namespaced: true - controller: true - domain: kagenti.dev - group: agent - kind: AgentBuild - path: github.com/kagenti/operator/api/v1alpha1 - version: v1alpha1 - webhooks: - defaulting: true - validation: true - webhookVersion: v1 version: "3" diff --git a/kagenti-operator/api/v1alpha1/agentcard_types.go b/kagenti-operator/api/v1alpha1/agentcard_types.go index 7c7810f7..4f7d90a2 100644 --- a/kagenti-operator/api/v1alpha1/agentcard_types.go +++ b/kagenti-operator/api/v1alpha1/agentcard_types.go @@ -37,29 +37,16 @@ type AgentCardSpec struct { IdentityBinding *IdentityBinding `json:"identityBinding,omitempty"` } -// SpiffeID represents a SPIFFE identity in the format spiffe:/// -// +kubebuilder:validation:Pattern=`^spiffe://[a-zA-Z0-9][a-zA-Z0-9\-\.]*[a-zA-Z0-9](/[a-zA-Z0-9\-\._~%!$&'()*+,;=:@]+)*$` -type SpiffeID string - // IdentityBinding configures workload identity binding for an AgentCard. -// The SPIFFE ID used for binding comes from the JWS protected header (sign -// with --spiffe-id). If the header lacks a spiffe_id, binding fails. +// The SPIFFE ID is extracted from the leaf certificate SAN URI in the x5c chain. +// Binding validates that the SPIFFE ID belongs to the configured trust domain. type IdentityBinding struct { - // Deprecated: No longer used; trust domain comes from the JWS protected header. + // TrustDomain overrides the operator-level --spire-trust-domain for this AgentCard. + // If empty, the operator flag value is used. // +optional // +kubebuilder:validation:Pattern=`^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$` TrustDomain string `json:"trustDomain,omitempty"` - // Deprecated: No longer used; SPIFFE ID comes from the JWS protected header. - // +optional - ExpectedSpiffeID SpiffeID `json:"expectedSpiffeID,omitempty"` - - // AllowedSpiffeIDs is the allowlist of SPIFFE IDs permitted to bind to this agent. - // The SPIFFE ID from the JWS protected header must match one of these entries. - // +required - // +kubebuilder:validation:MinItems=1 - AllowedSpiffeIDs []SpiffeID `json:"allowedSpiffeIDs"` - // Strict enables enforcement mode: binding failures trigger network isolation. // When false (default), results are recorded in status only (audit mode). // +optional @@ -77,7 +64,6 @@ type TargetRef struct { // +kubebuilder:validation:MinLength=1 Kind string `json:"kind"` - // Name is the name of the target resource // +kubebuilder:validation:MinLength=1 Name string `json:"name"` } @@ -117,7 +103,7 @@ type AgentCardStatus struct { // +optional SignatureKeyID string `json:"signatureKeyId,omitempty"` - // SignatureSpiffeID is the SPIFFE ID from the JWS protected header (set only when valid). + // SignatureSpiffeID is the SPIFFE ID from the leaf certificate SAN URI (set only when valid). // +optional SignatureSpiffeID string `json:"signatureSpiffeId,omitempty"` @@ -125,11 +111,11 @@ type AgentCardStatus struct { // +optional SignatureIdentityMatch *bool `json:"signatureIdentityMatch,omitempty"` - // CardId is the SHA256 hash of the JCS-canonicalized card content (optional drift detection) + // CardId is the SHA-256 hash of the card content for drift detection. // +optional CardId string `json:"cardId,omitempty"` - // ExpectedSpiffeID is the SPIFFE ID used for binding evaluation (from JWS protected header) + // ExpectedSpiffeID is the SPIFFE ID used for binding evaluation. // +optional ExpectedSpiffeID string `json:"expectedSpiffeID,omitempty"` @@ -140,7 +126,7 @@ type AgentCardStatus struct { // BindingStatus represents the result of identity binding evaluation type BindingStatus struct { - // Bound indicates whether the verified SPIFFE ID is in the allowlist + // Bound indicates whether the verified SPIFFE ID belongs to the configured trust domain Bound bool `json:"bound"` // Reason is a machine-readable reason for the binding status @@ -202,7 +188,7 @@ type AgentCardData struct { // AgentCardSignature represents a JWS signature on an AgentCard (A2A spec §8.4.2). type AgentCardSignature struct { - // Protected is the base64url-encoded JWS protected header (contains alg, kid, spiffe_id). + // Protected is the base64url-encoded JWS protected header (contains alg, kid, x5c). // +required Protected string `json:"protected"` @@ -258,7 +244,6 @@ type AgentSkill struct { // SkillParameter defines a parameter that a skill accepts type SkillParameter struct { - // Name is the parameter name // +optional Name string `json:"name,omitempty"` diff --git a/kagenti-operator/api/v1alpha1/zz_generated.deepcopy.go b/kagenti-operator/api/v1alpha1/zz_generated.deepcopy.go index a597679a..3ecf3abd 100644 --- a/kagenti-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/kagenti-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -189,7 +189,7 @@ func (in *AgentCardSpec) DeepCopyInto(out *AgentCardSpec) { if in.IdentityBinding != nil { in, out := &in.IdentityBinding, &out.IdentityBinding *out = new(IdentityBinding) - (*in).DeepCopyInto(*out) + **out = **in } } @@ -308,11 +308,6 @@ func (in *BindingStatus) DeepCopy() *BindingStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IdentityBinding) DeepCopyInto(out *IdentityBinding) { *out = *in - if in.AllowedSpiffeIDs != nil { - in, out := &in.AllowedSpiffeIDs, &out.AllowedSpiffeIDs - *out = make([]SpiffeID, len(*in)) - copy(*out, *in) - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IdentityBinding. diff --git a/kagenti-operator/cmd/agentcard-signer/Dockerfile b/kagenti-operator/cmd/agentcard-signer/Dockerfile new file mode 100644 index 00000000..fcba19da --- /dev/null +++ b/kagenti-operator/cmd/agentcard-signer/Dockerfile @@ -0,0 +1,21 @@ +FROM docker.io/golang:1.24 AS builder +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /workspace +COPY go.mod go.mod +COPY go.sum go.sum +RUN go mod download + +COPY cmd/agentcard-signer/ cmd/agentcard-signer/ +COPY api/ api/ +COPY internal/ internal/ + +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o agentcard-signer ./cmd/agentcard-signer/ + +FROM gcr.io/distroless/static:nonroot +WORKDIR / +COPY --from=builder /workspace/agentcard-signer . +USER 65532:65532 + +ENTRYPOINT ["/agentcard-signer"] diff --git a/kagenti-operator/cmd/agentcard-signer/main.go b/kagenti-operator/cmd/agentcard-signer/main.go new file mode 100644 index 00000000..22ba2714 --- /dev/null +++ b/kagenti-operator/cmd/agentcard-signer/main.go @@ -0,0 +1,303 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/spiffe/go-spiffe/v2/svid/x509svid" + "github.com/spiffe/go-spiffe/v2/workloadapi" + + agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" + "github.com/kagenti/operator/internal/signature" +) + +const ( + defaultSocket = "unix:///run/spire/sockets/agent.sock" + defaultUnsignedPath = "/etc/agentcard/agent.json" + defaultSignedPath = "/app/.well-known/agent.json" + defaultTimeout = "30s" +) + +func main() { + if err := run(); err != nil { + logJSON("error", "signing failed", "error", err.Error()) + os.Exit(1) + } +} + +func run() error { + socketPath := envOrDefault("SPIFFE_ENDPOINT_SOCKET", defaultSocket) + unsignedPath := envOrDefault("UNSIGNED_CARD_PATH", defaultUnsignedPath) + signedPath := envOrDefault("AGENT_CARD_PATH", defaultSignedPath) + timeoutStr := envOrDefault("SIGN_TIMEOUT", defaultTimeout) + + timeout, err := time.ParseDuration(timeoutStr) + if err != nil { + return fmt.Errorf("invalid SIGN_TIMEOUT %q: %w", timeoutStr, err) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + logJSON("info", "starting agentcard signer", + "socket", socketPath, + "unsigned_path", unsignedPath, + "signed_path", signedPath, + "timeout", timeoutStr, + ) + + svid, err := fetchSVID(ctx, socketPath) + if err != nil { + return fmt.Errorf("failed to fetch X.509-SVID: %w", err) + } + defer zeroPrivateKey(svid.PrivateKey) + + spiffeID := svid.ID.String() + logJSON("info", "fetched SVID", "spiffe_id", spiffeID) + + unsignedJSON, err := os.ReadFile(unsignedPath) + if err != nil { + return fmt.Errorf("failed to read unsigned card from %s: %w", unsignedPath, err) + } + + var cardData agentv1alpha1.AgentCardData + if err := json.Unmarshal(unsignedJSON, &cardData); err != nil { + return fmt.Errorf("failed to parse unsigned card JSON: %w", err) + } + + signedCard, err := signCard(&cardData, svid.PrivateKey, svid.Certificates) + if err != nil { + return fmt.Errorf("signing failed: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(signedPath), 0o755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + if err := os.WriteFile(signedPath, signedCard, 0o644); err != nil { + return fmt.Errorf("failed to write signed card to %s: %w", signedPath, err) + } + + logJSON("info", "signed card written successfully", + "spiffe_id", spiffeID, + "output_path", signedPath, + ) + return nil +} + +func fetchSVID(ctx context.Context, socketPath string) (*x509svid.SVID, error) { + client, err := workloadapi.New(ctx, workloadapi.WithAddr(socketPath)) + if err != nil { + return nil, fmt.Errorf("failed to create workload API client: %w", err) + } + defer client.Close() + + svid, err := client.FetchX509SVID(ctx) + if err != nil { + return nil, fmt.Errorf("FetchX509SVID failed: %w", err) + } + 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 + } + return defaultVal +} + +func logJSON(level, msg string, kvs ...string) { + entry := map[string]string{ + "level": level, + "msg": msg, + "ts": time.Now().UTC().Format(time.RFC3339), + } + for i := 0; i+1 < len(kvs); i += 2 { + entry[kvs[i]] = kvs[i+1] + } + data, _ := json.Marshal(entry) + fmt.Fprintln(os.Stderr, string(data)) +} diff --git a/kagenti-operator/cmd/agentcard-signer/main_test.go b/kagenti-operator/cmd/agentcard-signer/main_test.go new file mode 100644 index 00000000..4f97beb6 --- /dev/null +++ b/kagenti-operator/cmd/agentcard-signer/main_test.go @@ -0,0 +1,470 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "math/big" + "net/url" + "testing" + "time" + + agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" + "github.com/kagenti/operator/internal/signature" +) + +// --- Test CA helpers (mirrors x5c_test.go pattern) --- + +type testCA struct { + Key *ecdsa.PrivateKey + Cert *x509.Certificate +} + +func newTestCA(t *testing.T) *testCA { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test CA"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(certDER) + if err != nil { + t.Fatal(err) + } + return &testCA{Key: key, Cert: cert} +} + +func (ca *testCA) issueLeaf(t *testing.T, pub interface{}, spiffeID string) (*x509.Certificate, []byte) { + t.Helper() + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{CommonName: "Test Leaf"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(1 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + } + if spiffeID != "" { + u, _ := url.Parse(spiffeID) + tmpl.URIs = append(tmpl.URIs, u) + } + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, ca.Cert, pub, ca.Key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(certDER) + if err != nil { + t.Fatal(err) + } + return cert, certDER +} + +func testCard() *agentv1alpha1.AgentCardData { + return &agentv1alpha1.AgentCardData{ + Name: "test-agent", + Version: "1.0.0", + URL: "https://test.example.com/.well-known/agent.json", + } +} + +// --- signCard tests --- + +func TestSignCard_ECDSA_P256(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf, _ := ca.issueLeaf(t, &key.PublicKey, "spiffe://example.org/ns/default/sa/test") + + card := testCard() + output, err := signCard(card, key, []*x509.Certificate{leaf, ca.Cert}) + if err != nil { + t.Fatalf("signCard failed: %v", err) + } + + var parsed agentv1alpha1.AgentCardData + if err := json.Unmarshal(output, &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if len(parsed.Signatures) != 1 { + t.Fatalf("expected 1 signature, got %d", len(parsed.Signatures)) + } + + header, err := signature.DecodeProtectedHeader(parsed.Signatures[0].Protected) + if err != nil { + t.Fatalf("failed to decode protected header: %v", err) + } + if header.Algorithm != "ES256" { + t.Errorf("expected alg=ES256, got %s", header.Algorithm) + } + if header.Type != "JOSE" { + t.Errorf("expected typ=JOSE, got %s", header.Type) + } + if len(header.X5C) != 2 { + t.Errorf("expected 2 certs in x5c, got %d", len(header.X5C)) + } +} + +func TestSignCard_ECDSA_P384(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + leaf, _ := ca.issueLeaf(t, &key.PublicKey, "spiffe://example.org/agent") + + card := testCard() + output, err := signCard(card, key, []*x509.Certificate{leaf}) + if err != nil { + t.Fatalf("signCard failed: %v", err) + } + + var parsed agentv1alpha1.AgentCardData + if err := json.Unmarshal(output, &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + + header, _ := signature.DecodeProtectedHeader(parsed.Signatures[0].Protected) + if header.Algorithm != "ES384" { + t.Errorf("expected alg=ES384, got %s", header.Algorithm) + } +} + +func TestSignCard_RSA(t *testing.T) { + ca := newTestCA(t) + key, _ := rsa.GenerateKey(rand.Reader, 2048) + leaf, _ := ca.issueLeaf(t, &key.PublicKey, "spiffe://example.org/rsa-agent") + + card := testCard() + output, err := signCard(card, key, []*x509.Certificate{leaf}) + if err != nil { + t.Fatalf("signCard failed: %v", err) + } + + var parsed agentv1alpha1.AgentCardData + if err := json.Unmarshal(output, &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + + header, _ := signature.DecodeProtectedHeader(parsed.Signatures[0].Protected) + if header.Algorithm != "RS256" { + t.Errorf("expected alg=RS256, got %s", header.Algorithm) + } +} + +func TestSignCard_NilCardData(t *testing.T) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + _, err := signCard(nil, key, []*x509.Certificate{{}}) + if err == nil { + t.Error("expected error for nil card data") + } +} + +func TestSignCard_NoCertificates(t *testing.T) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + _, err := signCard(testCard(), key, nil) + if err == nil { + t.Error("expected error for empty cert chain") + } +} + +// --- ECDSA raw R||S encoding test --- + +func TestSignCard_ECDSA_RawRS_ByteLength(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf, _ := ca.issueLeaf(t, &key.PublicKey, "spiffe://example.org/agent") + + card := testCard() + output, err := signCard(card, key, []*x509.Certificate{leaf}) + if err != nil { + t.Fatalf("signCard failed: %v", err) + } + + var parsed agentv1alpha1.AgentCardData + json.Unmarshal(output, &parsed) + + sigBytes, err := base64.RawURLEncoding.DecodeString(parsed.Signatures[0].Signature) + if err != nil { + t.Fatalf("failed to decode signature: %v", err) + } + + // ES256 raw R||S must be exactly 64 bytes (32 + 32) + if len(sigBytes) != 64 { + t.Errorf("ES256 raw R||S signature must be 64 bytes, got %d (likely DER-encoded)", len(sigBytes)) + } +} + +func TestSignCard_ECDSA_P384_RawRS_ByteLength(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + leaf, _ := ca.issueLeaf(t, &key.PublicKey, "spiffe://example.org/agent") + + card := testCard() + output, err := signCard(card, key, []*x509.Certificate{leaf}) + if err != nil { + t.Fatalf("signCard failed: %v", err) + } + + var parsed agentv1alpha1.AgentCardData + json.Unmarshal(output, &parsed) + + sigBytes, _ := base64.RawURLEncoding.DecodeString(parsed.Signatures[0].Signature) + // ES384 raw R||S must be exactly 96 bytes (48 + 48) + if len(sigBytes) != 96 { + t.Errorf("ES384 raw R||S signature must be 96 bytes, got %d", len(sigBytes)) + } +} + +// --- x5c header construction tests --- + +func TestSignCard_X5C_StandardBase64(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf, leafDER := ca.issueLeaf(t, &key.PublicKey, "spiffe://example.org/agent") + + card := testCard() + output, _ := signCard(card, key, []*x509.Certificate{leaf, ca.Cert}) + + var parsed agentv1alpha1.AgentCardData + json.Unmarshal(output, &parsed) + + header, _ := signature.DecodeProtectedHeader(parsed.Signatures[0].Protected) + + // x5c must use standard base64 (not base64url) per RFC 7515 §4.1.6 + decoded, err := base64.StdEncoding.DecodeString(header.X5C[0]) + if err != nil { + t.Fatalf("x5c[0] is not valid standard base64: %v", err) + } + if string(decoded) != string(leafDER) { + t.Error("x5c[0] does not match leaf certificate DER") + } +} + +func TestSignCard_X5C_LeafFirst(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf, _ := ca.issueLeaf(t, &key.PublicKey, "spiffe://example.org/agent") + + card := testCard() + output, _ := signCard(card, key, []*x509.Certificate{leaf, ca.Cert}) + + var parsed agentv1alpha1.AgentCardData + json.Unmarshal(output, &parsed) + + header, _ := signature.DecodeProtectedHeader(parsed.Signatures[0].Protected) + + leafDER, _ := base64.StdEncoding.DecodeString(header.X5C[0]) + parsedLeaf, _ := x509.ParseCertificate(leafDER) + if parsedLeaf.IsCA { + t.Error("x5c[0] should be the leaf (non-CA), not the CA") + } + + caDER, _ := base64.StdEncoding.DecodeString(header.X5C[1]) + parsedCA, _ := x509.ParseCertificate(caDER) + if !parsedCA.IsCA { + t.Error("x5c[1] should be the CA certificate") + } +} + +// --- kid derivation test --- + +func TestComputeKID(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf, _ := ca.issueLeaf(t, &key.PublicKey, "spiffe://example.org/agent") + + kid := computeKID(leaf) + + fp := sha256.Sum256(leaf.Raw) + expected := big.NewInt(0).SetBytes(fp[:8]).Text(16) + // kid should be first 16 hex chars of SHA-256 fingerprint + if len(kid) != 16 { + t.Errorf("expected kid length 16, got %d: %s", len(kid), kid) + } + _ = expected // format may differ in leading zeros, just check length +} + +// --- algorithmForKey tests --- + +func TestAlgorithmForKey_ECDSA_P256(t *testing.T) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + alg, err := algorithmForKey(&key.PublicKey) + if err != nil { + t.Fatal(err) + } + if alg != "ES256" { + t.Errorf("expected ES256, got %s", alg) + } +} + +func TestAlgorithmForKey_ECDSA_P384(t *testing.T) { + key, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + alg, err := algorithmForKey(&key.PublicKey) + if err != nil { + t.Fatal(err) + } + if alg != "ES384" { + t.Errorf("expected ES384, got %s", alg) + } +} + +func TestAlgorithmForKey_ECDSA_P521(t *testing.T) { + key, _ := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + alg, err := algorithmForKey(&key.PublicKey) + if err != nil { + t.Fatal(err) + } + if alg != "ES512" { + t.Errorf("expected ES512, got %s", alg) + } +} + +func TestAlgorithmForKey_RSA(t *testing.T) { + key, _ := rsa.GenerateKey(rand.Reader, 2048) + alg, err := algorithmForKey(&key.PublicKey) + if err != nil { + t.Fatal(err) + } + if alg != "RS256" { + t.Errorf("expected RS256, got %s", alg) + } +} + +func TestAlgorithmForKey_RSA_TooSmall(t *testing.T) { + key, _ := rsa.GenerateKey(rand.Reader, 1024) + _, err := algorithmForKey(&key.PublicKey) + if err == nil { + t.Error("expected error for 1024-bit RSA key") + } +} + +// --- zeroPrivateKey tests --- + +func TestZeroPrivateKey_ECDSA(t *testing.T) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + zeroPrivateKey(key) + if key.D.Sign() != 0 { + t.Error("expected ECDSA D to be zeroed") + } +} + +func TestZeroPrivateKey_RSA(t *testing.T) { + key, _ := rsa.GenerateKey(rand.Reader, 2048) + zeroPrivateKey(key) + if key.D.Sign() != 0 { + t.Error("expected RSA D to be zeroed") + } + for i, p := range key.Primes { + if p.Sign() != 0 { + t.Errorf("expected RSA prime[%d] to be zeroed", i) + } + } +} + +// --- Canonical JSON cross-validation --- +// Signer uses signature.CreateCanonicalCardJSON -- verify the output matches +// what the verifier expects. + +func TestSignCard_CanonicalJSON_CrossValidation(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf, _ := ca.issueLeaf(t, &key.PublicKey, "spiffe://example.org/agent") + + card := testCard() + output, err := signCard(card, key, []*x509.Certificate{leaf}) + if err != nil { + t.Fatalf("signCard failed: %v", err) + } + + var parsed agentv1alpha1.AgentCardData + json.Unmarshal(output, &parsed) + + // Re-derive the canonical JSON from the parsed card (without signatures) + cardWithoutSigs := parsed + cardWithoutSigs.Signatures = nil + canonical, err := signature.CreateCanonicalCardJSON(&cardWithoutSigs) + if err != nil { + t.Fatalf("CreateCanonicalCardJSON failed: %v", err) + } + + // Reconstruct the signing input and verify the signature + sig := parsed.Signatures[0] + payloadB64 := base64.RawURLEncoding.EncodeToString(canonical) + signingInput := sig.Protected + "." + payloadB64 + + pubPEM, _ := signature.MarshalPublicKeyToPEM(&key.PublicKey) + result, err := signature.VerifyJWS(&cardWithoutSigs, &sig, pubPEM) + if err != nil { + t.Fatalf("VerifyJWS error: %v", err) + } + if !result.Verified { + t.Errorf("cross-validation failed: signer output not verified by VerifyJWS: %s", result.Details) + } + _ = signingInput +} + +// --- End-to-end: signer output verified by X5CProvider --- + +func TestSignCard_VerifiedByX5CProvider(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf, _ := ca.issueLeaf(t, &key.PublicKey, "spiffe://example.org/ns/default/sa/test") + + card := testCard() + output, err := signCard(card, key, []*x509.Certificate{leaf, ca.Cert}) + if err != nil { + t.Fatalf("signCard failed: %v", err) + } + + var parsed agentv1alpha1.AgentCardData + json.Unmarshal(output, &parsed) + + // Build an X5CProvider with the test CA + pool := x509.NewCertPool() + pool.AddCert(ca.Cert) + provider := &signature.X5CProvider{} + provider.SetTrustBundleForTest(pool) + + cardWithoutSigs := parsed + cardWithoutSigs.Signatures = nil + result, err := provider.VerifySignature(t.Context(), &cardWithoutSigs, parsed.Signatures) + if err != nil { + t.Fatalf("X5CProvider.VerifySignature error: %v", err) + } + if !result.Verified { + t.Errorf("X5CProvider rejected signer output: %s", result.Details) + } + if result.SpiffeID != "spiffe://example.org/ns/default/sa/test" { + t.Errorf("expected SPIFFE ID from cert SAN, got %q", result.SpiffeID) + } +} diff --git a/kagenti-operator/cmd/main.go b/kagenti-operator/cmd/main.go index 37ac6f65..c594fe58 100644 --- a/kagenti-operator/cmd/main.go +++ b/kagenti-operator/cmd/main.go @@ -18,10 +18,12 @@ package main import ( "crypto/tls" + "errors" "flag" "os" "path/filepath" "strings" + "time" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -64,16 +66,18 @@ func main() { var secureMetrics bool var enableHTTP2 bool var tlsOpts []func(*tls.Config) - // Signature verification flags + var requireA2ASignature bool var signatureAuditMode bool - var signatureProvider string - var signatureSecretName string - var signatureSecretNamespace string - var signatureSecretKey string - var signatureJWKSURL string var enforceNetworkPolicies bool + var spireTrustDomain string + var spireTrustBundleConfigMapName string + var spireTrustBundleConfigMapNS string + var spireTrustBundleConfigMapKey string + var spireTrustBundleRefreshInterval time.Duration + var svidExpiryGracePeriod time.Duration + flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") @@ -91,51 +95,43 @@ func main() { flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") - // Signature verification flags flag.BoolVar(&requireA2ASignature, "require-a2a-signature", false, "Require A2A agent cards to have a valid signature") flag.BoolVar(&signatureAuditMode, "signature-audit-mode", false, "When true, log signature verification failures but don't block (use for rollout)") - flag.StringVar(&signatureProvider, "signature-provider", "none", - "Signature verification provider type: 'secret', 'jwks', or 'none'") - flag.StringVar(&signatureSecretName, "signature-secret-name", "", - "Name of the Kubernetes Secret containing the signing public key") - flag.StringVar(&signatureSecretNamespace, "signature-secret-namespace", "", - "Namespace of the Kubernetes Secret containing the signing public key") - flag.StringVar(&signatureSecretKey, "signature-secret-key", "", - "Key within the Secret to use (if not set, auto-discovery is used)") - flag.StringVar(&signatureJWKSURL, "signature-jwks-url", "", - "URL of the JWKS endpoint for signature verification") flag.BoolVar(&enforceNetworkPolicies, "enforce-network-policies", false, "Create NetworkPolicies to restrict traffic for agents with unverified signatures") + flag.StringVar(&spireTrustDomain, "spire-trust-domain", "", + "SPIRE trust domain for identity binding (e.g. 'example.org')") + flag.StringVar(&spireTrustBundleConfigMapName, "spire-trust-bundle-configmap", "", + "Name of the ConfigMap containing the SPIRE trust bundle (SPIFFE JSON format)") + flag.StringVar(&spireTrustBundleConfigMapNS, "spire-trust-bundle-configmap-namespace", "", + "Namespace of the trust bundle ConfigMap") + flag.StringVar(&spireTrustBundleConfigMapKey, "spire-trust-bundle-configmap-key", "bundle.spiffe", + "Key within the trust bundle ConfigMap containing SPIFFE JSON data") + flag.DurationVar(&spireTrustBundleRefreshInterval, "spire-trust-bundle-refresh-interval", 5*time.Minute, + "How often to re-read the trust bundle") + flag.DurationVar(&svidExpiryGracePeriod, "svid-expiry-grace-period", 30*time.Minute, + "How far before the signing SVID expires to trigger a proactive workload restart for re-signing") + opts := zap.Options{ - Development: true, + Development: false, } opts.BindFlags(flag.CommandLine) flag.Parse() ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) - // if the enable-http2 flag is false (the default), http/2 should be disabled - // due to its vulnerabilities. More specifically, disabling http/2 will - // prevent from being vulnerable to the HTTP/2 Stream Cancellation and - // Rapid Reset CVEs. For more information see: - // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 - // - https://github.com/advisories/GHSA-4374-p667-p6c8 + // Mitigate CVE-2023-44487 (HTTP/2 Rapid Reset). disableHTTP2 := func(c *tls.Config) { - setupLog.Info("disabling http/2") c.NextProtos = []string{"http/1.1"} } - if !enableHTTP2 { tlsOpts = append(tlsOpts, disableHTTP2) } - // Create watchers for metrics and webhooks certificates var metricsCertWatcher, webhookCertWatcher *certwatcher.CertWatcher - - // Initial webhook TLS options webhookTLSOpts := tlsOpts if len(webhookCertPath) > 0 { @@ -161,10 +157,6 @@ func main() { TLSOpts: webhookTLSOpts, }) - // Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server. - // More info: - // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.0/pkg/metrics/server - // - https://book.kubebuilder.io/reference/metrics.html metricsServerOptions := metricsserver.Options{ BindAddress: metricsAddr, SecureServing: secureMetrics, @@ -172,21 +164,9 @@ func main() { } if secureMetrics { - // FilterProvider is used to protect the metrics endpoint with authn/authz. - // These configurations ensure that only authorized users and service accounts - // can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info: - // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.0/pkg/metrics/filters#WithAuthenticationAndAuthorization metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization } - // If the certificate is not specified, controller-runtime will automatically - // generate self-signed certificates for the metrics server. While convenient for development and testing, - // this setup is not recommended for production. - // - // TODO(user): If you enable certManager, uncomment the following lines: - // - [METRICS-WITH-CERTS] at config/default/kustomization.yaml to generate and use certificates - // managed by cert-manager for the metrics server. - // - [PROMETHEUS-WITH-CERTS] at config/prometheus/kustomization.yaml for TLS certification. if len(metricsCertPath) > 0 { setupLog.Info("Initializing metrics certificate watcher using provided certificates", "metrics-cert-path", metricsCertPath, "metrics-cert-name", metricsCertName, "metrics-cert-key", metricsCertKey) @@ -197,7 +177,7 @@ func main() { filepath.Join(metricsCertPath, metricsCertKey), ) if err != nil { - setupLog.Error(err, "to initialize metrics certificate watcher", "error", err) + setupLog.Error(err, "Failed to initialize metrics certificate watcher") os.Exit(1) } @@ -209,8 +189,6 @@ func main() { mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Metrics: metricsServerOptions, - // Cache that will be used to create the default Cache. By default, the cache - // will watch and list requested objects in all namespaces. Cache: cache.Options{ DefaultNamespaces: getNamespacesToWatch(), }, @@ -218,25 +196,12 @@ func main() { HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: "b7c4ae34.kagenti.dev", - // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily - // when the Manager ends. This requires the binary to immediately end when the - // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly - // speeds up voluntary leader transitions as the new leader don't have to wait - // LeaseDuration time first. - // - // In the default scaffold provided, the program ends immediately after - // the manager stops, so would be fine to enable this option. However, - // if you are doing or is intended to do any operation such as perform cleanups - // after the manager stops then its usage might be unsafe. - // LeaderElectionReleaseOnCancel: true, }) if err != nil { setupLog.Error(err, "unable to start manager") os.Exit(1) } - - // Initialize signature verification provider if !requireA2ASignature { setupLog.Info("WARNING: --require-a2a-signature is false. Identity binding requires " + "--require-a2a-signature=true to function. AgentCards with spec.identityBinding " + @@ -245,41 +210,52 @@ func main() { var sigProvider signature.Provider if requireA2ASignature { + if spireTrustDomain == "" { + setupLog.Error(errors.New("missing required flag"), "--spire-trust-domain is required when --require-a2a-signature=true") + os.Exit(1) + } + if spireTrustBundleConfigMapName == "" || spireTrustBundleConfigMapNS == "" { + setupLog.Error(errors.New("missing required flags"), + "--spire-trust-bundle-configmap and --spire-trust-bundle-configmap-namespace are required when --require-a2a-signature=true") + os.Exit(1) + } + sigConfig := &signature.Config{ - Type: signature.ProviderType(signatureProvider), - SecretName: signatureSecretName, - SecretNamespace: signatureSecretNamespace, - SecretKey: signatureSecretKey, - JWKSURL: signatureJWKSURL, - AuditMode: signatureAuditMode, + Type: signature.ProviderTypeX5C, + TrustBundleConfigMapName: spireTrustBundleConfigMapName, + TrustBundleConfigMapNS: spireTrustBundleConfigMapNS, + TrustBundleConfigMapKey: spireTrustBundleConfigMapKey, + TrustBundleRefreshInterval: spireTrustBundleRefreshInterval, + Client: mgr.GetClient(), } var providerErr error sigProvider, providerErr = signature.NewProvider(sigConfig) if providerErr != nil { - setupLog.Error(providerErr, "unable to create signature provider") + setupLog.Error(providerErr, "unable to create x5c signature provider") os.Exit(1) } setupLog.Info("Signature verification enabled", - "provider", signatureProvider, - "auditMode", signatureAuditMode, - "requireSignature", requireA2ASignature) + "provider", "x5c", + "trustDomain", spireTrustDomain, + "auditMode", signatureAuditMode) } if err = (&controller.AgentCardReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: mgr.GetEventRecorderFor("agentcard-controller"), - AgentFetcher: agentcard.NewFetcher(), - SignatureProvider: sigProvider, - RequireSignature: requireA2ASignature, - SignatureAuditMode: signatureAuditMode, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("agentcard-controller"), + AgentFetcher: agentcard.NewFetcher(), + SignatureProvider: sigProvider, + RequireSignature: requireA2ASignature, + SignatureAuditMode: signatureAuditMode, + SpireTrustDomain: spireTrustDomain, + SVIDExpiryGracePeriod: svidExpiryGracePeriod, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "AgentCard") os.Exit(1) } - // Network policy controller (optional, enforces network isolation based on signature verification) if enforceNetworkPolicies { if err = (&controller.AgentCardNetworkPolicyReconciler{ Client: mgr.GetClient(), @@ -291,8 +267,7 @@ func main() { } setupLog.Info("Network policy enforcement enabled for signature verification") } - // AgentCardSync controller watches Deployments and StatefulSets - // It automatically creates AgentCards for workloads with agent labels + if err = (&controller.AgentCardSyncReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), @@ -338,19 +313,19 @@ func main() { } } func getNamespacesToWatch() map[string]cache.Config { - - // NAMESPACES2WATCH specifies the namespace(s) to watch. - // If undefined, the operator will run with cluster scope. - namespace, found := os.LookupEnv("NAMESPACES2WATCH") - if !found { + namespace := strings.TrimSpace(os.Getenv("NAMESPACES2WATCH")) + if namespace == "" { return nil } namespaces := make(map[string]cache.Config) - if namespace != "" { - for _, ns := range strings.Split(namespace, ",") { + for _, ns := range strings.Split(namespace, ",") { + if ns = strings.TrimSpace(ns); ns != "" { namespaces[ns] = cache.Config{} } } + if len(namespaces) == 0 { + return nil + } return namespaces } diff --git a/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentcards.yaml b/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentcards.yaml index 020a4796..1a353aea 100644 --- a/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentcards.yaml +++ b/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentcards.yaml @@ -81,22 +81,6 @@ spec: identityBinding: description: IdentityBinding specifies SPIFFE identity binding configuration properties: - allowedSpiffeIDs: - description: |- - AllowedSpiffeIDs is the allowlist of SPIFFE IDs permitted to bind to this agent. - The SPIFFE ID from the JWS protected header must match one of these entries. - items: - description: SpiffeID represents a SPIFFE identity in the format - spiffe:/// - pattern: ^spiffe://[a-zA-Z0-9][a-zA-Z0-9\-\.]*[a-zA-Z0-9](/[a-zA-Z0-9\-\._~%!$&'()*+,;=:@]+)*$ - type: string - minItems: 1 - type: array - expectedSpiffeID: - description: 'Deprecated: No longer used; SPIFFE ID comes from - the JWS protected header.' - pattern: ^spiffe://[a-zA-Z0-9][a-zA-Z0-9\-\.]*[a-zA-Z0-9](/[a-zA-Z0-9\-\._~%!$&'()*+,;=:@]+)*$ - type: string strict: default: false description: |- @@ -104,12 +88,11 @@ spec: When false (default), results are recorded in status only (audit mode). type: boolean trustDomain: - description: 'Deprecated: No longer used; trust domain comes from - the JWS protected header.' + description: |- + TrustDomain overrides the operator-level --spire-trust-domain for this AgentCard. + If empty, the operator flag value is used. pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$ type: string - required: - - allowedSpiffeIDs type: object syncPeriod: default: 30s diff --git a/kagenti-operator/config/rbac/role.yaml b/kagenti-operator/config/rbac/role.yaml index ef2ab2b4..f796e7b2 100644 --- a/kagenti-operator/config/rbac/role.yaml +++ b/kagenti-operator/config/rbac/role.yaml @@ -4,6 +4,21 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch - apiGroups: - "" resources: diff --git a/kagenti-operator/config/samples/agentcard-with-identity-binding.yaml b/kagenti-operator/config/samples/agentcard-with-identity-binding.yaml deleted file mode 100644 index 0c4f3f2f..00000000 --- a/kagenti-operator/config/samples/agentcard-with-identity-binding.yaml +++ /dev/null @@ -1,55 +0,0 @@ -# Example AgentCard with Identity Binding Configuration -# This demonstrates the Step 1 identity binding feature from the RFC -apiVersion: agent.kagenti.dev/v1alpha1 -kind: AgentCard -metadata: - name: weather-agent-card - namespace: default -spec: - # How often to re-fetch the agent card - syncPeriod: "30s" - - # Selector to match the Agent resource - selector: - matchLabels: - app.kubernetes.io/name: weather-agent - kagenti.io/type: agent - - # Identity Binding Configuration (Step 1) - identityBinding: - # Trust domain for SPIFFE ID derivation - # If not specified, uses controller's default (typically "cluster.local") - trustDomain: "cluster.local" - - # Allowlist of SPIFFE IDs that can bind to this agent - # Format: spiffe:///ns//sa/ - allowedSpiffeIDs: - - "spiffe://cluster.local/ns/default/sa/weather-agent-sa" - - # Strict mode enforcement - # When true and binding fails, the Agent controller will scale the deployment to 0 - # When false, binding failures are logged but deployment continues running - strict: false - ---- -# Example with strict mode enabled -apiVersion: agent.kagenti.dev/v1alpha1 -kind: AgentCard -metadata: - name: secure-agent-card - namespace: production -spec: - syncPeriod: "1m" - selector: - matchLabels: - app.kubernetes.io/name: secure-agent - kagenti.io/type: agent - - identityBinding: - trustDomain: "prod.example.com" - allowedSpiffeIDs: - - "spiffe://prod.example.com/ns/production/sa/secure-agent-sa" - - "spiffe://prod.example.com/ns/production/sa/backup-agent-sa" - # Strict mode: if binding fails, the agent will be disabled (scaled to 0) - strict: true - diff --git a/kagenti-operator/config/samples/helloworld-build-and-deploy-no-dockerfile.yaml b/kagenti-operator/config/samples/helloworld-build-and-deploy-no-dockerfile.yaml deleted file mode 100644 index 88b8abcb..00000000 --- a/kagenti-operator/config/samples/helloworld-build-and-deploy-no-dockerfile.yaml +++ /dev/null @@ -1,87 +0,0 @@ -apiVersion: agent.kagenti.dev/v1alpha1 -kind: AgentBuild -metadata: - name: helloworld-agent-build - namespace: kagenti -spec: - mode: dev - source: - sourceRepository: "github.com/a2aproject/a2a-samples.git" - sourceRevision: "main" - sourceSubfolder: "samples/python/agents/helloworld" - sourceCredentials: - name: github-token-secret - - pipeline: - namespace: kagenti-system - parameters: - - name: SOURCE_REPO_SECRET - value: github-token-secret - - name: START_COMMAND # Custom start command - value: "python __main__.py" - - name: PYTHON_VERSION # Specify Python version - value: "3.13" - buildOutput: - image: "helloworld-service" - imageTag: "v0.0.1" - imageRegistry: "registry.cr-system.svc.cluster.local:5000" - imageRepoCredentials: - name: ghcr-secret - ---- -apiVersion: agent.kagenti.dev/v1alpha1 -kind: Agent -metadata: - name: helloworld-agent - namespace: kagenti - labels: - app: helloworld-service - - annotations: - description: "helloworld agent" -spec: - description: "Agent saying Hello" - - replicas: 1 - - # Use existing container image - imageSource: - buildRef: - name: helloworld-agent-build - - # Complete pod template specification - podTemplateSpec: - spec: - - containers: - - name: agent # use fixed name "agent" for the main container - image: - - # Environment variables - env: - - name: PORT - value: "8000" - - name: HOST - value: 0.0.0.0 - - name: OTEL_EXPORTER_OTLP_ENDPOINT - value: http://otel-collector.kagenti-system.svc.cluster.local:8335 - - name: KEYCLOAK_URL - value: http://keycloak.keycloak.svc.cluster.local:8080 - - name: UV_CACHE_DIR - value: /app/.cache/uv - - name: LLM_API_BASE - value: http://host.docker.internal:11434/v1 - - name: LLM_API_KEY - value: dummy - - name: LLM_MODEL - value: llama3.2:3b-instruct-fp16 - - name: MCP_URL - value: http://weather-tool:8000/mcp - - name: GITHUB_SECRET_NAME - value: github-token-secret - - name: CLIENT_NAME - value: kagenti/weather-service - - name: CLIENT_ID - value: spiffe://localtest.me/sa/weather-service - - name: NAMESPACE - value: kagenti diff --git a/kagenti-operator/config/samples/kustomization.yaml b/kagenti-operator/config/samples/kustomization.yaml index 35109d9e..cd2a5623 100644 --- a/kagenti-operator/config/samples/kustomization.yaml +++ b/kagenti-operator/config/samples/kustomization.yaml @@ -1,5 +1,4 @@ ## Append samples of your project ## resources: -- agent_v1alpha1_agent.yaml -- agent_v1alpha1_agentbuild.yaml +- weather-agent-image-deployment.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/kagenti-operator/config/samples/signature-verification/agent-with-signature.yaml b/kagenti-operator/config/samples/signature-verification/agent-with-signature.yaml deleted file mode 100644 index 1ea7b725..00000000 --- a/kagenti-operator/config/samples/signature-verification/agent-with-signature.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# Example: Agent with signature verification using the new targetRef approach -apiVersion: agent.kagenti.dev/v1alpha1 -kind: Agent -metadata: - name: weather-agent-signed - namespace: default - labels: - kagenti.io/type: agent - kagenti.io/protocol: a2a -spec: - image: example/weather-agent:v1.0.0 - replicas: 1 - env: - - name: AGENT_NAME - value: "Weather Agent" - - name: AGENT_DESCRIPTION - value: "Provides weather information" ---- -# AgentCard with targetRef (preferred) - references the Agent workload directly -apiVersion: agent.kagenti.dev/v1alpha1 -kind: AgentCard -metadata: - name: weather-agent-card - namespace: default -spec: - syncPeriod: "30s" - targetRef: - apiVersion: agent.kagenti.dev/v1alpha1 - kind: Agent - name: weather-agent-signed - # Optional: identity binding for SPIFFE ID verification - identityBinding: - allowedSpiffeIDs: - - "spiffe://cluster.local/ns/default/sa/weather-agent-signed-sa" diff --git a/kagenti-operator/config/samples/signature-verification/kustomization.yaml b/kagenti-operator/config/samples/signature-verification/kustomization.yaml deleted file mode 100644 index 56893e09..00000000 --- a/kagenti-operator/config/samples/signature-verification/kustomization.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - secret-with-public-key.yaml - - agent-with-signature.yaml - -namespace: default - - diff --git a/kagenti-operator/config/samples/signature-verification/secret-with-public-key.yaml b/kagenti-operator/config/samples/signature-verification/secret-with-public-key.yaml deleted file mode 100644 index 22705861..00000000 --- a/kagenti-operator/config/samples/signature-verification/secret-with-public-key.yaml +++ /dev/null @@ -1,27 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: a2a-public-keys - namespace: kagenti-system -type: Opaque -stringData: - # Example RSA public key in PEM format - # Replace this with your actual public key - public.pem: | - -----BEGIN PUBLIC KEY----- - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo - 4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u - +qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh - kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ - 0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg - cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc - mwIDAQAB - -----END PUBLIC KEY----- - - # You can add multiple keys with different IDs - # key-id-123.pem: | - # -----BEGIN PUBLIC KEY----- - # ... - # -----END PUBLIC KEY----- - - diff --git a/kagenti-operator/demos/agentcard-auto-discovery/demo.md b/kagenti-operator/demos/agentcard-auto-discovery/demo.md new file mode 100644 index 00000000..6616e551 --- /dev/null +++ b/kagenti-operator/demos/agentcard-auto-discovery/demo.md @@ -0,0 +1,58 @@ +# AgentCard Auto-Discovery Demo + +This demo shows how the operator's sync controller automatically discovers labeled workloads and creates AgentCard CRs for them. + +## Prerequisites + +- The kagenti operator is running with sync enabled. +- The `agents` namespace exists (deployed by `agentcard-spire-signing`). + +## What This Demonstrates + +| Scenario | What happens | +|----------|-------------| +| Deploy labeled workload | Sync controller auto-creates an AgentCard CR | +| Inspect auto-created card | Shows the card was created with correct targetRef | +| Cleanup | Removes echo-agent and auto-created cards | + +## Run the Demo + +```bash +./demos/agentcard-auto-discovery/run-demo-commands.sh +``` + +Expected output: + +``` +=== 1. Before: AgentCards in namespace === + (only weather-agent-card if spire-signing demo is deployed) + +=== 2. Deploying echo-agent (labeled, no AgentCard CR) === + deployment.apps/echo-agent created + service/echo-agent created + +=== 3. Auto-Created AgentCards === + NAME AGE + weather-agent-card ... + echo-agent-deployment-card ... + +=== 4. Auto-Created Card Details === + Name: echo-agent-deployment-card + TargetRef: Deployment/echo-agent + +=== 5. Cleanup === + echo-agent resources deleted +``` + +## How It Works + +1. The sync controller watches for Deployments labeled `kagenti.io/type: agent` +2. When a new labeled Deployment appears without a matching AgentCard, the controller creates one +3. The auto-created card uses the naming convention `-deployment-card` +4. If you later create a manual AgentCard targeting the same Deployment, it takes precedence + +## Cleanup + +```bash +./demos/agentcard-auto-discovery/teardown-demo.sh +``` diff --git a/kagenti-operator/demos/agentcard-auto-discovery/k8s/echo-agent.yaml b/kagenti-operator/demos/agentcard-auto-discovery/k8s/echo-agent.yaml new file mode 100644 index 00000000..8a47b112 --- /dev/null +++ b/kagenti-operator/demos/agentcard-auto-discovery/k8s/echo-agent.yaml @@ -0,0 +1,58 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: echo-agent + namespace: agents + labels: + kagenti.io/type: agent + kagenti.io/protocol: a2a + app.kubernetes.io/name: echo-agent +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: echo-agent + kagenti.io/type: agent + template: + metadata: + labels: + app.kubernetes.io/name: echo-agent + kagenti.io/type: agent + kagenti.io/protocol: a2a + spec: + containers: + - name: echo + image: docker.io/python:3.11-slim + command: + - python3 + - -c + - | + import http.server, json + class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == '/.well-known/agent.json': + card = {'name': 'Echo Agent', 'version': '1.0.0', + 'url': 'http://echo-agent.agents.svc:8001'} + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(card).encode()) + else: + self.send_response(404) + self.end_headers() + def log_message(self, *a): pass + http.server.HTTPServer(('', 8001), H).serve_forever() + ports: + - containerPort: 8001 +--- +apiVersion: v1 +kind: Service +metadata: + name: echo-agent + namespace: agents +spec: + selector: + app.kubernetes.io/name: echo-agent + ports: + - port: 8001 + targetPort: 8001 diff --git a/kagenti-operator/demos/agentcard-auto-discovery/run-demo-commands.sh b/kagenti-operator/demos/agentcard-auto-discovery/run-demo-commands.sh new file mode 100755 index 00000000..759bae86 --- /dev/null +++ b/kagenti-operator/demos/agentcard-auto-discovery/run-demo-commands.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +## +# Auto-discovery demo: sync controller creates AgentCards for labeled workloads. +# + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NAMESPACE="${NAMESPACE:-agents}" + +echo "=== 1. Before: AgentCards in namespace ===" +kubectl get agentcard -n "$NAMESPACE" --no-headers 2>/dev/null || echo " (none)" +echo "" + +echo "=== 2. Deploying echo-agent (labeled, no AgentCard CR) ===" +kubectl apply -f "${SCRIPT_DIR}/k8s/echo-agent.yaml" +echo "" + +echo "Waiting for pod to become ready..." +kubectl rollout status deployment/echo-agent -n "$NAMESPACE" --timeout=120s +echo "" + +echo "Waiting 30s for sync controller to discover the workload..." +sleep 30 + +echo "=== 3. Auto-Created AgentCards ===" +kubectl get agentcard -n "$NAMESPACE" +echo "" + +AUTOCARD=$(kubectl get agentcard -n "$NAMESPACE" -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' | grep echo || true) +if [ -n "$AUTOCARD" ]; then + echo "=== 4. Auto-Created Card Details ===" + kubectl get agentcard "$AUTOCARD" -n "$NAMESPACE" -o jsonpath='{.metadata.name}' | xargs -I{} echo " Name: {}" + kubectl get agentcard "$AUTOCARD" -n "$NAMESPACE" -o jsonpath=' TargetRef: {.spec.targetRef.kind}/{.spec.targetRef.name}' + echo "" +else + echo "=== 4. Auto-Created Card Details ===" + echo " (no auto-created card found for echo-agent)" +fi +echo "" + +echo "=== 5. Cleanup ===" +kubectl delete -f "${SCRIPT_DIR}/k8s/echo-agent.yaml" --ignore-not-found=true 2>/dev/null || true +if [ -n "$AUTOCARD" ]; then + kubectl delete agentcard "$AUTOCARD" -n "$NAMESPACE" --ignore-not-found=true 2>/dev/null || true +fi +sleep 5 +echo " echo-agent resources deleted" diff --git a/kagenti-operator/demos/agentcard-auto-discovery/teardown-demo.sh b/kagenti-operator/demos/agentcard-auto-discovery/teardown-demo.sh new file mode 100755 index 00000000..4e32ce40 --- /dev/null +++ b/kagenti-operator/demos/agentcard-auto-discovery/teardown-demo.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# +# Teardown for the auto-discovery demo. +# Removes the echo-agent and any auto-created AgentCards. +# + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NAMESPACE="${NAMESPACE:-agents}" + +echo "=== AgentCard Auto-Discovery Demo Teardown ===" +echo "" + +echo "Deleting echo-agent resources..." +kubectl delete -f "${SCRIPT_DIR}/k8s/echo-agent.yaml" --ignore-not-found=true 2>/dev/null || true + +echo "Deleting any auto-created AgentCards for echo-agent..." +AUTOCARD=$(kubectl get agentcard -n "$NAMESPACE" -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null | grep echo || true) +if [ -n "$AUTOCARD" ]; then + kubectl delete agentcard "$AUTOCARD" -n "$NAMESPACE" --ignore-not-found=true 2>/dev/null || true +fi + +echo "" +echo "=== Teardown Complete ===" diff --git a/kagenti-operator/demos/agentcard-enforcement/demo.md b/kagenti-operator/demos/agentcard-enforcement/demo.md new file mode 100644 index 00000000..f7c1e813 --- /dev/null +++ b/kagenti-operator/demos/agentcard-enforcement/demo.md @@ -0,0 +1,74 @@ +# AgentCard Enforcement Demo + +This demo shows how the operator enforces identity binding through trust-domain validation and NetworkPolicy. + +## Prerequisites + +- The `agentcard-spire-signing` demo must be deployed and passing (Verified=true, Bound=true). +- Operator running with `--enforce-network-policies=true`. + +## What This Demonstrates + +| Scenario | What happens | +|----------|-------------| +| Wrong trust domain | Signature stays valid, but binding fails — `Bound=false` | +| Binding failure (`strict: true`) | Label removed, restrictive NetworkPolicy applied | +| Binding failure (`strict: false`) | Label removed, restrictive NetworkPolicy applied | +| Restored trust domain | Binding passes — label restored, permissive NetworkPolicy | + +## Run the Demo + +```bash +./demos/agentcard-enforcement/run-demo-commands.sh +``` + +Expected output: + +``` +=== 1. Baseline (correct trust domain) === + Verified: True + Bound: True + Identity Match: True + Reason: Bound + Label: true + NetworkPolicy: weather-agent-signature-policy + +=== 2. Wrong Trust Domain (strict: true) === + Verified: True + Bound: False + Identity Match: False + Reason: NotBound + Label: + +=== 3. Wrong Trust Domain (strict: false) === + Verified: True + Bound: False + Identity Match: False + Reason: NotBound + Label: + +=== 4. NetworkPolicy After Binding Failure === + NetworkPolicy: weather-agent-signature-policy + +=== 5. Restored === + Verified: True + Bound: True + Identity Match: True + Reason: Bound + Label: true + NetworkPolicy: weather-agent-signature-policy +``` + +## How It Works + +1. The operator evaluates identity binding on every reconciliation +2. When `spec.identityBinding.trustDomain` is set, it overrides the operator-level `--spire-trust-domain` +3. If the SPIFFE ID from the x5c chain doesn't match the configured trust domain, binding fails +4. Binding failure always removes the `signature-verified` label from the workload +5. The NetworkPolicy controller applies a restrictive policy when the label is absent, permissive when present + +## Cleanup + +```bash +./demos/agentcard-enforcement/teardown-demo.sh +``` diff --git a/kagenti-operator/demos/agentcard-enforcement/run-demo-commands.sh b/kagenti-operator/demos/agentcard-enforcement/run-demo-commands.sh new file mode 100755 index 00000000..3c2e8b26 --- /dev/null +++ b/kagenti-operator/demos/agentcard-enforcement/run-demo-commands.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +## +# Enforcement demo: trust domain rejection, binding failure enforcement, NetworkPolicy. +# Prerequisite: agentcard-spire-signing demo must be deployed. +# + +set -eu + +NAMESPACE="${NAMESPACE:-agents}" +AGENTCARD="${AGENTCARD:-weather-agent-card}" +DEPLOYMENT="${DEPLOYMENT:-weather-agent}" + +get_status() { + kubectl get agentcard "$AGENTCARD" -n "$NAMESPACE" -o jsonpath='{.status}' | python3 -c " +import sys, json +s = json.loads(sys.stdin.read()) +print(f' Verified: {s.get(\"validSignature\")}') +print(f' Bound: {s.get(\"bindingStatus\", {}).get(\"bound\")}') +print(f' Identity Match: {s.get(\"signatureIdentityMatch\")}') +print(f' Reason: {s.get(\"bindingStatus\", {}).get(\"reason\")}') +" +} + +get_label() { + local val + val=$(kubectl get deployment "$DEPLOYMENT" -n "$NAMESPACE" \ + -o jsonpath='{.spec.template.metadata.labels.agent\.kagenti\.dev/signature-verified}') + echo " Label: ${val:-}" +} + +get_netpol() { + local pol + pol=$(kubectl get networkpolicy -n "$NAMESPACE" --no-headers 2>/dev/null || true) + if [ -n "$pol" ]; then + echo " NetworkPolicy: $(echo "$pol" | awk '{print $1}')" + else + echo " NetworkPolicy: " + fi +} + +# ── 1. Baseline ────────────────────────────────────────────────────────────── +echo "=== 1. Baseline (correct trust domain) ===" +get_status +get_label +get_netpol +echo "" + +# ── 2. Wrong trust domain with strict: true ────────────────────────────────── +echo "=== 2. Wrong Trust Domain (strict: true) ===" +kubectl patch agentcard "$AGENTCARD" -n "$NAMESPACE" --type=merge -p '{ + "spec": { + "identityBinding": { + "trustDomain": "wrong.example.com", + "strict": true + } + } +}' +echo "Waiting for reconciliation..." +sleep 20 +get_status +get_label +echo "" + +# ── 3. Wrong trust domain with strict: false ───────────────────────────────── +echo "=== 3. Wrong Trust Domain (strict: false) ===" +kubectl patch agentcard "$AGENTCARD" -n "$NAMESPACE" --type=merge -p '{ + "spec": { + "identityBinding": { + "trustDomain": "wrong.example.com", + "strict": false + } + } +}' +echo "Waiting for reconciliation..." +sleep 20 +get_status +get_label +echo "" + +# ── 4. NetworkPolicy after binding failure ─────────────────────────────────── +echo "=== 4. NetworkPolicy After Binding Failure ===" +get_netpol +echo "" + +# ── 5. Restore correct binding ────────────────────────────────────────────── +echo "=== 5. Restoring correct binding ===" +kubectl patch agentcard "$AGENTCARD" -n "$NAMESPACE" --type=json -p '[ + {"op": "remove", "path": "/spec/identityBinding"} +]' +kubectl patch agentcard "$AGENTCARD" -n "$NAMESPACE" --type=merge -p '{ + "spec": { + "identityBinding": { + "strict": true + } + } +}' +echo "Waiting for reconciliation..." +sleep 20 +echo "" +echo "=== 5. Restored ===" +get_status +get_label +get_netpol diff --git a/kagenti-operator/demos/agentcard-enforcement/teardown-demo.sh b/kagenti-operator/demos/agentcard-enforcement/teardown-demo.sh new file mode 100755 index 00000000..6eca9d27 --- /dev/null +++ b/kagenti-operator/demos/agentcard-enforcement/teardown-demo.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# +# Teardown for the enforcement demo. +# Restores the AgentCard to its correct binding state. +# + +set -eu + +NAMESPACE="${NAMESPACE:-agents}" +AGENTCARD="${AGENTCARD:-weather-agent-card}" + +echo "=== AgentCard Enforcement Demo Teardown ===" +echo "" + +echo "Restoring identity binding to default (strict: true, no trust domain override)..." +kubectl patch agentcard "$AGENTCARD" -n "$NAMESPACE" --type=json -p '[ + {"op": "remove", "path": "/spec/identityBinding"} +]' 2>/dev/null || true + +kubectl patch agentcard "$AGENTCARD" -n "$NAMESPACE" --type=merge -p '{ + "spec": { + "identityBinding": { + "strict": true + } + } +}' + +echo "Waiting for reconciliation..." +sleep 15 + +echo "" +echo "=== Teardown Complete ===" diff --git a/kagenti-operator/demos/agentcard-proactive-restart/demo.md b/kagenti-operator/demos/agentcard-proactive-restart/demo.md new file mode 100644 index 00000000..9b5b1bdf --- /dev/null +++ b/kagenti-operator/demos/agentcard-proactive-restart/demo.md @@ -0,0 +1,63 @@ +# AgentCard Proactive Restart Demo + +This demo shows how the operator detects upcoming SVID expiry and triggers a rolling restart so workloads always have fresh signatures. + +## Prerequisites + +- The `agentcard-spire-signing` demo must be deployed and passing (Verified=true, Bound=true). +- SPIRE is issuing SVIDs with a finite TTL (typically ~4h). + +## What This Demonstrates + +| Phase | What happens | +|-------|-------------| +| Baseline | Record current pod name, key ID, and annotations | +| Trigger | Set `--svid-expiry-grace-period=999h` so the check always fires | +| Verify | New pod running, new key ID, `resign-trigger` annotation set, `ResignTriggered` events | +| Restore | Return operator to normal `30m` grace period | + +## How the Trick Works + +The operator checks `time.Until(leafNotAfter) < gracePeriod` on every reconciliation. SPIRE issues SVIDs with a ~4h TTL. By temporarily setting `--svid-expiry-grace-period=999h`, the check always evaluates to true (4h < 999h), forcing an immediate restart. This proves the restart logic end-to-end without waiting hours for real expiry. + +## Run the Demo + +```bash +./demos/agentcard-proactive-restart/run-demo-commands.sh +``` + +Expected output: + +``` +=== Part A: Baseline === + Pod: weather-agent-abc123 + Baseline KeyId: a1b2c3d4e5f6g7h8 + resign-trigger: (not set) + +=== Part B: Triggering SVID expiry restart === + Patching operator with --svid-expiry-grace-period=999h... + Waiting for operator rollout... + Waiting 30s for reconciliation... + +=== Part C: Verify Restart === + Operator logs: "Triggering proactive workload restart for re-signing" + resign-trigger: 2026-02-20T12:00:00Z + Events: ResignTriggered + Current pods: weather-agent-xyz789 (new) + +=== Part D: Restore & Verify === + Restoring --svid-expiry-grace-period=30m... + validSignature: True + signatureKeyId: (different from baseline) + Bound: True +``` + +## Why This Proves Both SVID and CA Rotation Work + +The SVID expiry restart and CA rotation restart share the same code path (`maybeRestartForResign`). SVID expiry checks `time.Until(leafNotAfter) < grace`, while CA rotation checks `workloadBundleHash != currentBundleHash`. Both trigger `triggerRolloutRestart`, which sets the `resign-trigger` annotation and updates the `bundle-hash`. This demo exercises the full path end-to-end. + +## Cleanup + +```bash +./demos/agentcard-proactive-restart/teardown-demo.sh +``` diff --git a/kagenti-operator/demos/agentcard-proactive-restart/run-demo-commands.sh b/kagenti-operator/demos/agentcard-proactive-restart/run-demo-commands.sh new file mode 100755 index 00000000..a38e1422 --- /dev/null +++ b/kagenti-operator/demos/agentcard-proactive-restart/run-demo-commands.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +## +# Proactive restart demo: SVID expiry detection and automatic re-signing. +# Prerequisite: agentcard-spire-signing demo must be deployed. +# +# The operator args below must match your deployment. Adjust if your +# operator uses different flags. +# + +set -eu + +NAMESPACE="${NAMESPACE:-agents}" +AGENTCARD="${AGENTCARD:-weather-agent-card}" +DEPLOYMENT="${DEPLOYMENT:-weather-agent}" +OPERATOR_NS="${OPERATOR_NS:-agentcard-system}" +OPERATOR_DEPLOY="${OPERATOR_DEPLOY:-agentcard-operator}" +SPIRE_TRUST_DOMAIN="${SPIRE_TRUST_DOMAIN:-demo.example.com}" + +OPERATOR_ARGS_BASE=( + "--leader-elect=false" + "--metrics-bind-address=0" + "--health-probe-bind-address=:8081" + "--require-a2a-signature=true" + "--spire-trust-domain=${SPIRE_TRUST_DOMAIN}" + "--spire-trust-bundle-configmap=spire-bundle" + "--spire-trust-bundle-configmap-namespace=spire-system" + "__GRACE__" + "--webhook-cert-path=/tmp/k8s-webhook-server/serving-certs" + "--enforce-network-policies=true" +) + +patch_operator_grace() { + local grace="$1" + local args_json + args_json=$(printf '%s\n' "${OPERATOR_ARGS_BASE[@]}" | sed "s/__GRACE__/--svid-expiry-grace-period=${grace}/" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read().strip().split('\n')))") + kubectl patch deployment "$OPERATOR_DEPLOY" -n "$OPERATOR_NS" --type=json \ + -p "[{\"op\": \"replace\", \"path\": \"/spec/template/spec/containers/0/args\", \"value\": ${args_json}}]" +} + +# ── Part A: Baseline ───────────────────────────────────────────────────────── +echo "=== Part A: Baseline ===" +BASELINE_POD=$(kubectl get pods -n "$NAMESPACE" -l app.kubernetes.io/name="$DEPLOYMENT" \ + --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') +echo " Pod: $BASELINE_POD" + +BASELINE_KEYID=$(kubectl get agentcard "$AGENTCARD" -n "$NAMESPACE" \ + -o jsonpath='{.status.signatureKeyId}') +echo " Baseline KeyId: ${BASELINE_KEYID:-(none)}" + +RESIGN=$(kubectl get deployment "$DEPLOYMENT" -n "$NAMESPACE" \ + -o jsonpath='{.spec.template.metadata.annotations.agentcard\.kagenti\.dev/resign-trigger}' 2>/dev/null || true) +echo " resign-trigger: ${RESIGN:-(not set)}" +echo "" + +# ── Part B: Trigger ────────────────────────────────────────────────────────── +echo "=== Part B: Triggering SVID expiry restart ===" +echo " Patching operator with --svid-expiry-grace-period=999h..." +patch_operator_grace "999h" +echo " Waiting for operator rollout..." +kubectl rollout status deployment/"$OPERATOR_DEPLOY" -n "$OPERATOR_NS" --timeout=120s +echo " Waiting 30s for reconciliation..." +sleep 30 +echo "" + +# ── Part C: Verify ─────────────────────────────────────────────────────────── +echo "=== Part C: Verify Restart ===" +echo " Operator logs (restart-related):" +kubectl logs -n "$OPERATOR_NS" deployment/"$OPERATOR_DEPLOY" 2>&1 | \ + grep -i -E "proactive|resign|restart|expir" | tail -5 || echo " (no matching log lines)" +echo "" + +RESIGN_AFTER=$(kubectl get deployment "$DEPLOYMENT" -n "$NAMESPACE" \ + -o jsonpath='{.spec.template.metadata.annotations.agentcard\.kagenti\.dev/resign-trigger}' 2>/dev/null || true) +echo " resign-trigger: ${RESIGN_AFTER:-(not set)}" + +echo "" +echo " ResignTriggered events:" +kubectl get events -n "$NAMESPACE" --field-selector reason=ResignTriggered --no-headers 2>/dev/null || echo " (none)" + +echo "" +echo " Current pods:" +kubectl get pods -n "$NAMESPACE" -l app.kubernetes.io/name="$DEPLOYMENT" --no-headers +echo "" + +# ── Part D: Restore ────────────────────────────────────────────────────────── +echo "=== Part D: Restore & Verify ===" +echo " Restoring --svid-expiry-grace-period=30m..." +patch_operator_grace "30m" +kubectl rollout status deployment/"$OPERATOR_DEPLOY" -n "$OPERATOR_NS" --timeout=120s +echo " Waiting 30s for stabilization..." +sleep 30 + +echo "" +echo " AgentCard status after restart cycle:" +kubectl get agentcard "$AGENTCARD" -n "$NAMESPACE" -o jsonpath='{.status}' | python3 -c " +import sys, json +s = json.loads(sys.stdin.read()) +print(f' validSignature: {s.get(\"validSignature\")}') +print(f' signatureKeyId: {s.get(\"signatureKeyId\")}') +print(f' identityMatch: {s.get(\"signatureIdentityMatch\")}') +print(f' bound: {s.get(\"bindingStatus\", {}).get(\"bound\")}') +" + +NEW_KEYID=$(kubectl get agentcard "$AGENTCARD" -n "$NAMESPACE" \ + -o jsonpath='{.status.signatureKeyId}') +echo "" +if [ "$BASELINE_KEYID" != "$NEW_KEYID" ]; then + echo " Key rotated: ${BASELINE_KEYID} -> ${NEW_KEYID}" +else + echo " WARNING: Key ID unchanged (${BASELINE_KEYID}). The restart may not have completed yet." +fi diff --git a/kagenti-operator/demos/agentcard-proactive-restart/teardown-demo.sh b/kagenti-operator/demos/agentcard-proactive-restart/teardown-demo.sh new file mode 100755 index 00000000..daa7fd16 --- /dev/null +++ b/kagenti-operator/demos/agentcard-proactive-restart/teardown-demo.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# +# Teardown for the proactive restart demo. +# Restores the operator to the normal grace period. +# + +set -eu + +OPERATOR_NS="${OPERATOR_NS:-agentcard-system}" +OPERATOR_DEPLOY="${OPERATOR_DEPLOY:-agentcard-operator}" +SPIRE_TRUST_DOMAIN="${SPIRE_TRUST_DOMAIN:-demo.example.com}" + +echo "=== AgentCard Proactive Restart Demo Teardown ===" +echo "" + +echo "Restoring operator to --svid-expiry-grace-period=30m..." +kubectl patch deployment "$OPERATOR_DEPLOY" -n "$OPERATOR_NS" --type=json -p "[ + {\"op\": \"replace\", \"path\": \"/spec/template/spec/containers/0/args\", + \"value\": [ + \"--leader-elect=false\", + \"--metrics-bind-address=0\", + \"--health-probe-bind-address=:8081\", + \"--require-a2a-signature=true\", + \"--spire-trust-domain=${SPIRE_TRUST_DOMAIN}\", + \"--spire-trust-bundle-configmap=spire-bundle\", + \"--spire-trust-bundle-configmap-namespace=spire-system\", + \"--svid-expiry-grace-period=30m\", + \"--webhook-cert-path=/tmp/k8s-webhook-server/serving-certs\", + \"--enforce-network-policies=true\" + ]} +]" +kubectl rollout status deployment/"$OPERATOR_DEPLOY" -n "$OPERATOR_NS" --timeout=120s + +echo "" +echo "=== Teardown Complete ===" diff --git a/kagenti-operator/demos/agentcard-spire-signing/demo.md b/kagenti-operator/demos/agentcard-spire-signing/demo.md new file mode 100644 index 00000000..1a03da32 --- /dev/null +++ b/kagenti-operator/demos/agentcard-spire-signing/demo.md @@ -0,0 +1,155 @@ +# SPIRE Signing Demo + +This demo shows automated AgentCard signing via a SPIRE init-container and operator-side x5c signature verification with trust-domain identity binding. + +## Overview + +``` + SPIRE Server + | + issues X.509-SVID + | + v +Agent Pod Operator Pod ++---------------------------+ +---------------------------+ +| | | | +| init: sign-agentcard | | agentcard-operator | +| fetches SVID from SPIRE| | fetches /.well-known/ | +| signs card with JWS | | verifies x5c chain | +| writes to shared vol | | validates trust domain | +| | | sets Verified + Bound | +| main: serves signed card | <-- fetch --- | | +| at /.well-known/ | | | ++---------------------------+ +---------------------------+ +``` + +The operator verifies the JWS signature using the x5c certificate chain embedded in the protected header, then validates that the leaf certificate's SPIFFE ID belongs to the configured trust domain. + +## Prerequisites + +- Kubernetes cluster with SPIRE installed (e.g. `kagenti/deployments/run_install.sh --env dev`) +- `spire-controller-manager` running (for ClusterSPIFFEID support) +- SPIFFE CSI driver available (`csi.spiffe.io`) +- Trust bundle ConfigMap in the cluster (e.g. `spire-bundle` in `spire-system`) +- kagenti-operator deployed with signature verification flags (see step 2 below) + +## Setup + +### 1. Build Images + +Build the agentcard-signer init-container image and load it into Kind: + +```bash +cd kagenti-operator/ + +# Build the signer image +make build-signer + +# Load into Kind (default cluster name is "kagenti") +make load-signer-image + +# Or specify a different cluster name +make load-signer-image KIND_CLUSTER_NAME= +``` + +### 2. Configure the Operator + +The operator must be started with these flags for signature verification: + +``` +--require-a2a-signature=true +--spire-trust-domain= +--spire-trust-bundle-configmap=spire-bundle +--spire-trust-bundle-configmap-namespace=spire-system +--enforce-network-policies=true +``` + +If using the Helm chart, set these in your values override. + +### 3. Deploy the Demo + +```bash +kubectl apply -f demos/agentcard-spire-signing/k8s/namespace.yaml +kubectl apply -f demos/agentcard-spire-signing/k8s/clusterspiffeid.yaml +kubectl apply -f demos/agentcard-spire-signing/k8s/agent-deployment.yaml +kubectl apply -f demos/agentcard-spire-signing/k8s/agentcard.yaml +``` + +### 4. Wait for Pods + +```bash +kubectl wait --for=condition=available --timeout=120s deployment/weather-agent -n agents +``` + +## Test the Flow + +Run the demo script to see signing and verification in action: + +```bash +./demos/agentcard-spire-signing/run-demo-commands.sh +``` + +Expected output: + +``` +=== 1. Init-Container Signing Logs === +{"level":"info","msg":"starting agentcard signer",...} +{"level":"info","msg":"fetched SVID","spiffe_id":"spiffe:///ns/agents/sa/weather-agent-sa",...} +{"level":"info","msg":"signed card written successfully",...} + +=== 2. Signed Card Verification === + Name: Weather Agent + Signed: True + Signatures: 1 + +=== 3. JWS Protected Header === + Algorithm: ES256 + Type: JOSE + Key ID: <16-char hex> + x5c certs: 1 + +=== 4. Operator Verification Status === + SignatureVerified: True (SignatureValid) + Bound: True (Bound) + Synced: True (SyncSucceeded) + +=== 5. Identity Binding === + SPIFFE ID: spiffe:///ns/agents/sa/weather-agent-sa + Identity Match: True + Bound: True + +=== 6. Signature Label === + agent.kagenti.dev/signature-verified: true + +=== 7. AgentCard Summary === +NAME PROTOCOL KIND TARGET AGENT VERIFIED BOUND SYNCED ... +weather-agent-card a2a Deployment weather-agent Weather Agent true true True ... +``` + +## How It Works + +1. The `sign-agentcard` init-container fetches an X.509-SVID from SPIRE via the Workload API +2. It signs the unsigned AgentCard JSON with JWS (ES256), embedding the certificate chain in the `x5c` header +3. The signed card is written to a shared `emptyDir` volume +4. The main container serves the signed card at `/.well-known/agent.json` +5. The operator fetches the card, verifies the JWS signature against the SPIRE trust bundle +6. The operator extracts the SPIFFE ID from the leaf certificate's SAN URI +7. If the SPIFFE ID belongs to the configured trust domain, the card is marked as Bound +8. The `agent.kagenti.dev/signature-verified` label is set on the workload + +## Cleanup + +Use the teardown script to delete all demo resources: + +```bash +./demos/agentcard-spire-signing/teardown-demo.sh +``` + +Or manually: + +```bash +kubectl delete -f demos/agentcard-spire-signing/k8s/agentcard.yaml +kubectl delete -f demos/agentcard-spire-signing/k8s/agent-deployment.yaml +kubectl delete -f demos/agentcard-spire-signing/k8s/clusterspiffeid.yaml +kubectl delete -f demos/agentcard-spire-signing/k8s/namespace.yaml +``` diff --git a/kagenti-operator/demos/agentcard-spire-signing/k8s/agent-deployment.yaml b/kagenti-operator/demos/agentcard-spire-signing/k8s/agent-deployment.yaml new file mode 100644 index 00000000..4e596836 --- /dev/null +++ b/kagenti-operator/demos/agentcard-spire-signing/k8s/agent-deployment.yaml @@ -0,0 +1,129 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: weather-agent-sa + namespace: agents +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: weather-agent-card-unsigned + namespace: agents +data: + agent.json: | + { + "name": "Weather Agent", + "description": "Provides weather forecasts and current conditions", + "url": "http://weather-agent.agents.svc.cluster.local:8080", + "version": "1.0.0", + "capabilities": { + "streaming": false, + "pushNotifications": false + }, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [ + { + "name": "get_weather", + "description": "Get current weather for a location", + "inputModes": ["text/plain"], + "outputModes": ["text/plain"] + } + ] + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: weather-agent + namespace: agents + labels: + kagenti.io/type: agent + kagenti.io/protocol: a2a + app.kubernetes.io/name: weather-agent +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: weather-agent + kagenti.io/type: agent + template: + metadata: + labels: + app.kubernetes.io/name: weather-agent + kagenti.io/type: agent + kagenti.io/protocol: a2a + spec: + serviceAccountName: weather-agent-sa + initContainers: + - name: sign-agentcard + # Local/Kind: kagenti/agentcard-signer:latest (make build-signer && make load-signer-image) + # Remote: push to a registry accessible by your cluster (e.g. ttl.sh, ghcr.io) + image: kagenti/agentcard-signer:latest + imagePullPolicy: IfNotPresent + env: + - name: SPIFFE_ENDPOINT_SOCKET + value: unix:///run/spire/agent-sockets/spire-agent.sock + - name: UNSIGNED_CARD_PATH + value: /etc/agentcard/agent.json + - name: AGENT_CARD_PATH + value: /app/.well-known/agent.json + - name: SIGN_TIMEOUT + value: "30s" + volumeMounts: + - name: spire-agent-socket + mountPath: /run/spire/agent-sockets + readOnly: true + - name: unsigned-card + mountPath: /etc/agentcard + readOnly: true + - name: signed-card + mountPath: /app/.well-known + securityContext: + runAsNonRoot: true + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 100m + memory: 32Mi + containers: + - name: agent + image: docker.io/python:3.11-slim + command: ["python3", "-m", "http.server", "8080", "--directory", "/app"] + ports: + - containerPort: 8080 + volumeMounts: + - name: signed-card + mountPath: /app/.well-known + readOnly: true + volumes: + - name: spire-agent-socket + csi: + driver: csi.spiffe.io + readOnly: true + - name: unsigned-card + configMap: + name: weather-agent-card-unsigned + - name: signed-card + emptyDir: + medium: Memory + sizeLimit: 1Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: weather-agent + namespace: agents +spec: + selector: + app.kubernetes.io/name: weather-agent + ports: + - port: 8080 + targetPort: 8080 diff --git a/kagenti-operator/demos/agentcard-spire-signing/k8s/agentcard.yaml b/kagenti-operator/demos/agentcard-spire-signing/k8s/agentcard.yaml new file mode 100644 index 00000000..1f619a06 --- /dev/null +++ b/kagenti-operator/demos/agentcard-spire-signing/k8s/agentcard.yaml @@ -0,0 +1,13 @@ +apiVersion: agent.kagenti.dev/v1alpha1 +kind: AgentCard +metadata: + name: weather-agent-card + namespace: agents +spec: + syncPeriod: "30s" + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: weather-agent + identityBinding: + strict: true diff --git a/kagenti-operator/demos/agentcard-spire-signing/k8s/clusterspiffeid.yaml b/kagenti-operator/demos/agentcard-spire-signing/k8s/clusterspiffeid.yaml new file mode 100644 index 00000000..b9e581d6 --- /dev/null +++ b/kagenti-operator/demos/agentcard-spire-signing/k8s/clusterspiffeid.yaml @@ -0,0 +1,12 @@ +apiVersion: spire.spiffe.io/v1alpha1 +kind: ClusterSPIFFEID +metadata: + name: agents +spec: + spiffeIDTemplate: "spiffe://{{ .TrustDomain }}/ns/{{ .PodMeta.Namespace }}/sa/{{ .PodSpec.ServiceAccountName }}" + podSelector: + matchLabels: + kagenti.io/type: agent + namespaceSelector: + matchLabels: + agentcard: "true" diff --git a/kagenti-operator/demos/agentcard-spire-signing/k8s/namespace.yaml b/kagenti-operator/demos/agentcard-spire-signing/k8s/namespace.yaml new file mode 100644 index 00000000..927e63d8 --- /dev/null +++ b/kagenti-operator/demos/agentcard-spire-signing/k8s/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: agents + labels: + agentcard: "true" diff --git a/kagenti-operator/demos/agentcard-spire-signing/run-demo-commands.sh b/kagenti-operator/demos/agentcard-spire-signing/run-demo-commands.sh new file mode 100755 index 00000000..77677c75 --- /dev/null +++ b/kagenti-operator/demos/agentcard-spire-signing/run-demo-commands.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +## +# Run verification commands for the SPIRE signing demo. +# Assumes setup is complete (see demo.md). +# + +set -eu + +NAMESPACE="${NAMESPACE:-agents}" + +POD=$(kubectl get pods -n "$NAMESPACE" -l app.kubernetes.io/name=weather-agent \ + --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') + +echo "=== 1. Init-Container Signing Logs ===" +kubectl logs -n "$NAMESPACE" "$POD" -c sign-agentcard +echo "" + +echo "=== 2. Signed Card Verification ===" +kubectl exec -n "$NAMESPACE" "$POD" -c agent -- python3 -c " +import json +with open('/app/.well-known/agent.json') as f: + d = json.load(f) +print(f' Name: {d.get(\"name\")}') +print(f' Signed: {\"signatures\" in d}') +print(f' Signatures: {len(d.get(\"signatures\", []))}') +" +echo "" + +echo "=== 3. JWS Protected Header ===" +kubectl get agentcard weather-agent-card -n "$NAMESPACE" \ + -o jsonpath='{.status.card.signatures[0].protected}' | python3 -c " +import sys, base64, json +b64 = sys.stdin.read().strip() +header = json.loads(base64.urlsafe_b64decode(b64 + '==')) +print(f' Algorithm: {header.get(\"alg\")}') +print(f' Type: {header.get(\"typ\")}') +print(f' Key ID: {header.get(\"kid\")}') +print(f' x5c certs: {len(header.get(\"x5c\", []))}') +" +echo "" + +echo "=== 4. Operator Verification Status ===" +kubectl get agentcard weather-agent-card -n "$NAMESPACE" \ + -o jsonpath='{.status.conditions}' | python3 -c " +import sys, json +for c in json.loads(sys.stdin.read()): + if c['type'] == 'SignatureVerified': + print(f' SignatureVerified: {c[\"status\"]} ({c[\"reason\"]})') + if c['type'] == 'Bound': + print(f' Bound: {c[\"status\"]} ({c[\"reason\"]})') + if c['type'] == 'Synced': + print(f' Synced: {c[\"status\"]} ({c[\"reason\"]})') +" +echo "" + +echo "=== 5. Identity Binding ===" +kubectl get agentcard weather-agent-card -n "$NAMESPACE" \ + -o jsonpath='{.status}' | python3 -c " +import sys, json +s = json.loads(sys.stdin.read()) +print(f' SPIFFE ID: {s.get(\"signatureSpiffeId\", \"(none)\")}') +print(f' Identity Match: {s.get(\"signatureIdentityMatch\")}') +print(f' Bound: {s.get(\"bindingStatus\", {}).get(\"bound\")}') +" +echo "" + +echo "=== 6. Signature Label ===" +LABEL=$(kubectl get deployment weather-agent -n "$NAMESPACE" \ + -o jsonpath='{.spec.template.metadata.labels.agent\.kagenti\.dev/signature-verified}') +echo " agent.kagenti.dev/signature-verified: ${LABEL:-}" +echo "" + +echo "=== 7. AgentCard Summary ===" +kubectl get agentcard -n "$NAMESPACE" diff --git a/kagenti-operator/demos/agentcard-spire-signing/teardown-demo.sh b/kagenti-operator/demos/agentcard-spire-signing/teardown-demo.sh new file mode 100755 index 00000000..98eb2e69 --- /dev/null +++ b/kagenti-operator/demos/agentcard-spire-signing/teardown-demo.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# +# Teardown script for the SPIRE signing demo. +# Deletes k8s resources created by the demo. +# + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +K8S_DIR="${SCRIPT_DIR}/k8s" + +NAMESPACE="${NAMESPACE:-agents}" + +echo "=== SPIRE Signing Demo Teardown ===" +echo "" + +echo "Deleting Kubernetes resources..." +kubectl delete -f "${K8S_DIR}/agentcard.yaml" --ignore-not-found=true 2>/dev/null || true +kubectl delete -f "${K8S_DIR}/agent-deployment.yaml" --ignore-not-found=true 2>/dev/null || true +kubectl delete -f "${K8S_DIR}/clusterspiffeid.yaml" --ignore-not-found=true 2>/dev/null || true +echo "Kubernetes resources deleted." +echo "" + +echo "Deleting namespace '${NAMESPACE}'..." +kubectl delete namespace "${NAMESPACE}" --wait=false --ignore-not-found=true 2>/dev/null || true + +# On shared OpenShift clusters, namespaces can get stuck in Terminating +# due to stale API groups (e.g. kubevirt). Force-finalize if needed. +sleep 5 +if kubectl get namespace "${NAMESPACE}" 2>/dev/null | grep -q Terminating; then + echo "Namespace stuck in Terminating — force-finalizing..." + kubectl get namespace "${NAMESPACE}" -o json | \ + python3 -c "import sys,json; ns=json.load(sys.stdin); ns['spec']['finalizers']=[]; print(json.dumps(ns))" | \ + kubectl replace --raw "/api/v1/namespaces/${NAMESPACE}/finalize" -f - >/dev/null 2>&1 || true +fi +echo "Namespace deleted." +echo "" + +echo "=== Teardown Complete ===" diff --git a/kagenti-operator/docs/a2a-signature-verification.md b/kagenti-operator/docs/a2a-signature-verification.md deleted file mode 100644 index 28013bc2..00000000 --- a/kagenti-operator/docs/a2a-signature-verification.md +++ /dev/null @@ -1,849 +0,0 @@ -# A2A AgentCard Signature Verification Setup Guide - -**GitHub Issue:** [#116 - Feature: Strict CardSignature Checking](https://github.com/kagenti/kagenti-operator/issues/116) - -This guide walks you through setting up A2A AgentCard signature verification. By the end, you'll have a working system where only agents with **cryptographically signed AgentCards** can communicate. - -> **Note:** Signature verification and identity binding work together. When both are configured, an agent must pass **both** checks to get network access. Signature alone works if identity binding is not configured. - ---- - -## Table of Contents - -1. [Overview](#1-overview) -2. [Prerequisites](#2-prerequisites) -3. [Architecture](#3-architecture) -4. [Setup](#4-setup) -5. [Demo Scenarios](#5-demo-scenarios) -6. [Troubleshooting](#6-troubleshooting) -7. [Reference](#7-reference) -8. [Cleanup](#8-cleanup) - ---- - -## 1. Overview - -**Kagenti Operator** is a Kubernetes operator that manages AI agents following the [A2A Protocol](https://a2a-protocol.org/). Agents discover each other by publishing an **AgentCard** (a JSON document describing the agent's capabilities). - -### What Signature Verification Provides - -| Without Signature Verification | With Signature Verification | -|--------------------------------|----------------------------| -| Any pod can claim to be any agent | Only agents with cards signed by your private key are accepted | - -### Key Features - -- **JWS signatures**: RSA and ECDSA using JWS Compact Serialization (RFC 7515) with canonical JSON payload -- **Multiple providers**: Kubernetes Secrets or JWKS endpoints -- **Audit mode**: Log failures without blocking agents -- **NetworkPolicy enforcement**: Network-level isolation of unverified agents -- **Zero-downtime key rotation**: Rotate signing keys without disrupting running agents -- **Prometheus metrics**: Counters, histograms, and error tracking - ---- - -## 2. Prerequisites - -| Tool | Version | Verify | -|------|---------|--------| -| kubectl | v1.28+ | `kubectl version --client` | -| helm | v3.0+ | `helm version` | -| openssl | any | `openssl version` | -| python3 | 3.8+ | `python3 --version` | -| Docker/Podman | any | `docker version` | - -**Python packages** (for signing): -```bash -pip3 install cryptography -``` - -**Kubernetes cluster options:** -- **Local:** kind, minikube, k3d, or Docker Desktop -- **Cloud:** EKS, GKE, AKS, OpenShift - -**Clone the repository:** - -```bash -git clone https://github.com/kagenti/kagenti-operator.git -cd kagenti-operator -``` - -> All commands assume you're in the `kagenti-operator` directory. - ---- - -## 3. Architecture - -### High-Level Flow - -```mermaid -flowchart LR - A["Agent\n(serves /agent-card)"] - B["AgentCard Controller\n(fetches card)"] - C{"Signature\nrequired?"} - D["Accept"] - E["Provider\n(verifies signature)"] - F{"Valid?"} - G{"Audit\nmode?"} - H["Warn & Accept"] - I["Reject"] - - A -->|"HTTP GET"| B - B --> C - C -->|"No"| D - C -->|"Yes"| E - E --> F - F -->|"Yes"| D - F -->|"No"| G - G -->|"Yes"| H - G -->|"No"| I - - classDef agentClass fill:#e1f5ff,stroke:#01579b,stroke-width:2px - classDef operatorClass fill:#fff3e0,stroke:#e65100,stroke-width:2px - classDef successClass fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px - classDef warnClass fill:#fff9c4,stroke:#f57f17,stroke-width:2px - classDef failClass fill:#ffcdd2,stroke:#c62828,stroke-width:2px - - class A agentClass - class B,C,E,F,G operatorClass - class D successClass - class H warnClass - class I failClass -``` - -### Signature Format (JWS) - -The operator verifies signatures embedded in the AgentCard JSON using **JWS JSON Serialization** (A2A spec §8.4.2): - -```json -{ - "name": "My Agent", - "url": "http://my-agent:8000", - "signatures": [ - { - "protected": "eyJhbGciOiJSUzI1NiIsImtpZCI6Im15LXNpZ25pbmcta2V5IiwidHlwIjoiSk9TRSJ9", - "signature": "base64url-encoded-JWS-signature" - } - ] -} -``` - -The `protected` field is a base64url-encoded JSON header containing: -- `alg`: signature algorithm (e.g., `RS256`, `ES256`) -- `kid`: key identifier matching a key in the Secret/JWKS -- `typ`: `JOSE` (required by A2A spec) -- `spiffe_id`: optional SPIFFE identity of the signer (for identity binding) - -Verification steps: -1. Decode the JWS protected header → extract `alg`, `kid`, `spiffe_id` -2. Validate the algorithm (reject `none`, verify key type matches) -3. Strip the `signatures` field from the card → create **canonical JSON** (sorted keys, no whitespace) -4. Reconstruct signing input: `BASE64URL(protected) || '.' || BASE64URL(canonical_payload)` -5. Verify the cryptographic signature against the public key - -### Component Responsibilities - -| Component | Code Location | -|-----------|---------------| -| AgentCardReconciler | `internal/controller/agentcard_controller.go` | -| Provider Interface | `internal/signature/provider.go` | -| Secret Provider | `internal/signature/secret.go` | -| JWKS Provider | `internal/signature/jwks.go` | -| Verifier (core crypto) | `internal/signature/verifier.go` | -| NetworkPolicy Controller | `internal/controller/agentcard_networkpolicy_controller.go` | -| Metrics | `internal/signature/metrics.go` | - ---- - -## 4. Setup - -### Step 1: Install Dependencies - -```bash -# Create cluster (skip if you have one) -kind create cluster --name kagenti-demo - -# Install cert-manager (for webhook certificates) -kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.yaml -kubectl wait --for=condition=Available deployment/cert-manager -n cert-manager --timeout=120s -``` - -### Step 2: Generate Keys and Create Secret - -```bash -# Generate RSA key pair -openssl genrsa -out private-key.pem 2048 -openssl rsa -in private-key.pem -pubout -out public-key.pem - -# Create namespace and secret -kubectl create namespace kagenti-system -kubectl label namespace kagenti-system control-plane=kagenti-operator -kubectl create secret generic a2a-public-keys \ - --from-file=public.pem=public-key.pem \ - --from-file=my-signing-key=public-key.pem \ - --namespace=kagenti-system -``` - -> ⚠️ **Security:** Keep `private-key.pem` secure. Never commit it to git. - -### Step 3: Install Kagenti Operator - -```bash -# Build the operator (runs in a subshell so we stay in the repo root) -(cd kagenti-operator && make docker-build IMG=kagenti-operator:dev) -kind load docker-image kagenti-operator:dev --name kagenti-demo - -kubectl create namespace kagenti-system 2>/dev/null || true -helm install kagenti-operator charts/kagenti-operator \ - --namespace kagenti-system \ - --set signatureVerification.enabled=true \ - --set signatureVerification.provider=secret \ - --set signatureVerification.secret.name=a2a-public-keys \ - --set signatureVerification.secret.namespace=kagenti-system \ - --set controllerManager.container.image.repository=kagenti-operator \ - --set controllerManager.container.image.tag=dev \ - --set controllerManager.container.cmd=/manager - -# Verify -kubectl wait --for=condition=Available deployment/kagenti-controller-manager \ - -n kagenti-system --timeout=120s -kubectl logs -n kagenti-system deployment/kagenti-controller-manager | grep -i signature -``` - -> **Note:** `--set controllerManager.container.cmd=/manager` is required for locally-built images. Production releases use `/ko-app/cmd`. - -### Step 4: Sign and Deploy an Agent - -The repository includes a JWS signing script at `kagenti-operator/scripts/sign-agent-card.py`. It produces signatures conforming to A2A spec §8.4.2. - -**Sign and deploy:** - -```bash -# Create unsigned card -cat > my-agent-card.json << 'EOF' -{ - "name": "Weather Agent", - "description": "Provides weather information for any location", - "version": "1.0.0", - "url": "http://weather-agent.default.svc.cluster.local:8000", - "capabilities": {"streaming": true, "pushNotifications": false}, - "defaultInputModes": ["text/plain"], - "defaultOutputModes": ["application/json"] -} -EOF - -# Sign with private key (JWS format) -python3 kagenti-operator/scripts/sign-agent-card.py my-agent-card.json private-key.pem \ - --key-id my-signing-key --output signed-agent-card.json - -# Create ConfigMap from signed card -cat > weather-agent-configmap.yaml << EOF -apiVersion: v1 -kind: ConfigMap -metadata: - name: weather-agent-card - namespace: default -data: - agent.json: | -$(cat signed-agent-card.json | sed 's/^/ /') -EOF - -kubectl apply -f weather-agent-configmap.yaml - -# Deploy Deployment + Service + AgentCard (targetRef) -cat < unsigned-card.json << 'EOF' -{ - "name": "Rogue Agent", - "description": "No signature", - "version": "1.0.0", - "url": "http://rogue-agent.default.svc.cluster.local:8000", - "capabilities": {"streaming": false, "pushNotifications": false}, - "defaultInputModes": ["text/plain"], - "defaultOutputModes": ["application/json"] -} -EOF - -cat > rogue-configmap.yaml << EOF -apiVersion: v1 -kind: ConfigMap -metadata: - name: rogue-agent-card - namespace: default -data: - agent.json: | -$(cat unsigned-card.json | sed 's/^/ /') -EOF - -kubectl apply -f rogue-configmap.yaml -cat < tampered-card.json << 'EOF' -{ - "name": "Tampered Agent", - "description": "Signed with wrong key", - "version": "1.0.0", - "url": "http://tampered-agent.default.svc.cluster.local:8000", - "capabilities": {"streaming": false, "pushNotifications": false}, - "defaultInputModes": ["text/plain"], - "defaultOutputModes": ["application/json"] -} -EOF -python3 kagenti-operator/scripts/sign-agent-card.py tampered-card.json wrong-private-key.pem \ - --key-id my-signing-key --output tampered-signed.json - -cat > tampered-configmap.yaml << EOF -apiVersion: v1 -kind: ConfigMap -metadata: - name: tampered-agent-card - namespace: default -data: - agent.json: | -$(cat tampered-signed.json | sed 's/^/ /') -EOF - -kubectl apply -f tampered-configmap.yaml -cat < **Key Takeaway:** Audit mode is ideal for rolling out signature verification gradually — monitor failures before enforcing. - ---- - -### Demo 4: NetworkPolicy Enforcement - -Enable network-level blocking of unverified agents: - -```bash -helm upgrade kagenti-operator charts/kagenti-operator \ - --namespace kagenti-system \ - --set signatureVerification.enabled=true \ - --set signatureVerification.provider=secret \ - --set signatureVerification.secret.name=a2a-public-keys \ - --set signatureVerification.secret.namespace=kagenti-system \ - --set signatureVerification.enforceNetworkPolicies=true \ - --set controllerManager.container.image.repository=kagenti-operator \ - --set controllerManager.container.image.tag=dev \ - --set controllerManager.container.cmd=/manager - -# Wait for rollout -kubectl rollout status deployment/kagenti-controller-manager -n kagenti-system --timeout=120s - -# Verify NetworkPolicies created -kubectl get networkpolicy -n default - -# Check operator logs -kubectl logs -n kagenti-system deployment/kagenti-controller-manager | grep -i "networkpolicy\|restrictive\|permissive" -``` - -**Expected:** -- Verified agents → **permissive** NetworkPolicy (traffic allowed) -- Unverified agents → **restrictive** NetworkPolicy (traffic blocked) - -> **Note:** The JWKS endpoint must serve a standard [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517) JSON Web Key Set. Each key must have a `kid` that matches the `kid` in the JWS protected header. - ---- - -### Demo 5: JWKS Provider - -Use a JWKS endpoint instead of Kubernetes Secrets: - -```bash -helm upgrade kagenti-operator charts/kagenti-operator \ - --namespace kagenti-system \ - --set signatureVerification.enabled=true \ - --set signatureVerification.provider=jwks \ - --set signatureVerification.jwks.url=https://your-domain.com/.well-known/jwks.json \ - --set controllerManager.container.image.repository=kagenti-operator \ - --set controllerManager.container.image.tag=dev \ - --set controllerManager.container.cmd=/manager - -# Wait for rollout -kubectl rollout status deployment/kagenti-controller-manager -n kagenti-system --timeout=120s -kubectl get agentcard -o wide -kubectl logs -n kagenti-system deployment/kagenti-controller-manager | grep -i jwks -``` - -**Expected:** Operator fetches keys from the JWKS endpoint and verifies signatures identically to the Secret provider. - ---- - -### Demo 6: Key Rotation (Zero Downtime) - -Rotate signing keys without disrupting running agents: - -```bash -# Step 1: Generate new key pair -openssl genrsa -out new-private-key.pem 2048 -openssl rsa -in new-private-key.pem -pubout -out new-public-key.pem - -# Step 2: Add both old and new keys to secret -kubectl create secret generic a2a-public-keys \ - --from-file=old-key=public-key.pem \ - --from-file=new-key=new-public-key.pem \ - --namespace=kagenti-system \ - --dry-run=client -o yaml | kubectl apply -f - -kubectl rollout restart deployment/kagenti-controller-manager -n kagenti-system -kubectl rollout status deployment/kagenti-controller-manager -n kagenti-system --timeout=120s - -# Verify: agent signed with OLD key still passes -kubectl get agentcard weather-agent-card -o wide -# Expected: VERIFIED=true (old key still in secret) - -# Step 3: Re-sign agents with new key -python3 kagenti-operator/scripts/sign-agent-card.py my-agent-card.json new-private-key.pem \ - --key-id new-key --output signed-agent-card.json -cat > weather-agent-configmap.yaml << EOF -apiVersion: v1 -kind: ConfigMap -metadata: - name: weather-agent-card - namespace: default -data: - agent.json: | -$(cat signed-agent-card.json | sed 's/^/ /') -EOF -kubectl apply -f weather-agent-configmap.yaml -kubectl rollout restart deployment/weather-agent -n default -kubectl rollout status deployment/weather-agent -n default --timeout=120s - -# Verify: agent now uses new key -kubectl get agentcard weather-agent-card -o jsonpath='{.status.signatureKeyId}' -# Expected: "new-key" - -# Step 4: Remove old key after all agents migrated -kubectl delete secret a2a-public-keys -n kagenti-system -kubectl create secret generic a2a-public-keys \ - --from-file=new-key=new-public-key.pem \ - --namespace=kagenti-system -kubectl rollout restart deployment/kagenti-controller-manager -n kagenti-system -kubectl rollout status deployment/kagenti-controller-manager -n kagenti-system --timeout=120s - -# Verify: agent still passes with only new key -kubectl get agentcard weather-agent-card -o wide -# Expected: VERIFIED=true -``` - -> **Key Takeaway:** Always have a transition period where both keys are active. Never remove the old key until all agents are re-signed. - ---- - -## 6. Troubleshooting - -| Issue | Cause | Solution | -|-------|-------|----------| -| `"AgentCard does not contain any signatures"` | AgentCard JSON has no `signatures` array | Sign the card using `kagenti-operator/scripts/sign-agent-card.py` | -| `"key not found in secret"` | `kid` in JWS header doesn't match any key in secret | Verify key names: `kubectl get secret a2a-public-keys -n kagenti-system -o jsonpath='{.data}' \| jq` | -| `"JWS signature verification failed"` | Signature doesn't match card content | Ensure correct private key and card hasn't changed after signing | -| `"Algorithm validation failed"` | Unsupported or missing `alg` in JWS header | Check signing script uses `RS256`, `ES256`, or another supported algorithm | -| `"Algorithm mismatch"` | JWS header `alg` doesn't match public key type | Ensure RSA key with `RS256` or ECDSA key with `ES256` | -| `"failed to fetch secret"` | RBAC permissions issue | Check: `kubectl auth can-i get secrets -n kagenti-system --as=system:serviceaccount:kagenti-system:kagenti-controller-manager` | -| AgentCard stuck in Pending | Agent pod not serving card | Check pod: `kubectl get pods -l app.kubernetes.io/name=weather-agent` | - -**Debug commands:** - -```bash -# Check operator logs -kubectl logs -n kagenti-system deployment/kagenti-controller-manager | grep -i "verif\|signature" - -# Check AgentCard status -kubectl get agentcard -o yaml - -# Check NetworkPolicies -kubectl get networkpolicy -n -``` - ---- - -## 7. Reference - -### Helm Values - -| Parameter | Description | Default | -|-----------|-------------|---------| -| `signatureVerification.enabled` | Enable signature verification | `false` | -| `signatureVerification.provider` | Provider type: `secret`, `jwks`, `none` | `"none"` | -| `signatureVerification.secret.name` | K8s secret name with public keys | `""` | -| `signatureVerification.secret.namespace` | Secret namespace | `""` | -| `signatureVerification.secret.key` | Specific key in secret (auto-detect if empty) | `""` | -| `signatureVerification.jwks.url` | JWKS endpoint URL | `""` | -| `signatureVerification.auditMode` | Log failures without blocking | `false` | -| `signatureVerification.enforceNetworkPolicies` | Create NetworkPolicies for unverified agents | `false` | - -### CLI Flags - -| Flag | Description | -|------|-------------| -| `--require-a2a-signature` | Enable signature verification | -| `--signature-provider` | Provider type: `secret`, `jwks`, `none` | -| `--signature-secret-name` | Secret name for public keys | -| `--signature-secret-namespace` | Secret namespace | -| `--signature-secret-key` | Specific key in the secret | -| `--signature-jwks-url` | JWKS endpoint URL | -| `--signature-audit-mode` | Enable audit mode | -| `--enforce-network-policies` | Enable NetworkPolicy enforcement | - -### Status Fields - -| Field | Description | -|-------|-------------| -| `status.validSignature` | `true` if JWS signature is verified | -| `status.signatureVerificationDetails` | Human-readable verification result | -| `status.signatureKeyId` | Key ID (`kid`) extracted from JWS protected header | -| `status.signatureSpiffeId` | SPIFFE ID extracted from JWS protected header (if present and signature valid) | -| `status.signatureIdentityMatch` | `true` only when BOTH signature AND identity binding pass | -| `status.bindingStatus.bound` | `true` if identity binding check passes | -| `conditions[type=SignatureVerified]` | `True`/`False` with reason | -| `conditions[type=Bound]` | `True`/`False` with binding evaluation result | - -### Prometheus Metrics - -| Metric | Type | Description | -|--------|------|-------------| -| `a2a_signature_verification_total` | Counter | Total attempts (labels: provider, result, audit_mode) | -| `a2a_signature_verification_errors_total` | Counter | Errors (labels: provider, error_type) | -| `a2a_signature_verification_duration_seconds` | Histogram | Duration (labels: provider) | - ---- - -## 8. Cleanup - -```bash -kubectl delete agentcard weather-agent-card rogue-agent-card tampered-agent-card -n default 2>/dev/null -kubectl delete deployment weather-agent rogue-agent tampered-agent -n default 2>/dev/null -kubectl delete service weather-agent rogue-agent tampered-agent -n default 2>/dev/null -kubectl delete configmap weather-agent-card rogue-agent-card tampered-agent-card -n default 2>/dev/null -helm uninstall kagenti-operator -n kagenti-system -kubectl delete secret a2a-public-keys -n kagenti-system -kubectl delete namespace kagenti-system -kind delete cluster --name kagenti-demo -rm -f private-key.pem public-key.pem new-private-key.pem new-public-key.pem wrong-private-key.pem \ - signed-agent-card.json my-agent-card.json unsigned-card.json tampered-card.json tampered-signed.json \ - weather-agent-configmap.yaml rogue-configmap.yaml tampered-configmap.yaml -``` - ---- - -## Summary - -| What You Built | Description | -|----------------|-------------| -| JWS signature verification | Only signed AgentCards accepted (RSA/ECDSA via JWS RFC 7515) | -| Identity binding integration | SPIFFE ID from JWS header used for workload identity verification | -| Multiple providers | Kubernetes Secrets or JWKS endpoints | -| Audit mode | Gradual rollout without blocking | -| NetworkPolicy enforcement | Network-level isolation — requires signature (and binding, if configured) to pass | -| Key rotation | Zero-downtime key migration | - -**Production recommendations:** -- Use 4096-bit RSA or ECDSA P-256 keys -- Store private keys in a vault (HashiCorp Vault, AWS KMS) -- Enable NetworkPolicy enforcement -- Configure identity binding with `--spiffe-id` during signing for defense-in-depth -- Set up Prometheus alerts on `a2a_signature_verification_errors_total` -- Automate key rotation diff --git a/kagenti-operator/docs/agentcard-identity-binding.md b/kagenti-operator/docs/agentcard-identity-binding.md new file mode 100644 index 00000000..eaddf5a7 --- /dev/null +++ b/kagenti-operator/docs/agentcard-identity-binding.md @@ -0,0 +1,126 @@ +# AgentCard Identity Binding + +This guide explains how AgentCards are bound to workload identities using SPIRE-issued X.509-SVIDs. Identity binding ensures that only agents running in the expected trust domain can have their AgentCards accepted. + +--- + +## Overview + +| Without Identity Binding | With Identity Binding | +|--------------------------|----------------------| +| Any agent with a valid signature is accepted | Only agents whose SPIFFE ID belongs to the configured trust domain are accepted | + +Identity binding uses **trust-domain validation**: the SPIFFE ID extracted from the leaf certificate's SAN URI must belong to the configured trust domain. This is cryptographically enforced -- the SPIFFE ID comes from the x5c certificate chain, not from a self-asserted header claim. + +--- + +## How It Works + +```mermaid +flowchart LR + A["Agent\n(serves signed card\nwith x5c chain)"] + B["AgentCard Controller\n(fetches card)"] + C{"x5c chain\nvalid?"} + D["Extract SPIFFE ID\nfrom leaf cert SAN"] + E{"Trust domain\nmatches?"} + F["Bound=true\nNetwork access"] + G["Bound=false\nNetwork blocked"] + H["Reject card"] + + A -->|"HTTP GET"| B + B --> C + C -->|"Yes"| D + C -->|"No"| H + D --> E + E -->|"Yes"| F + E -->|"No"| G + + classDef agentClass fill:#e1f5ff,stroke:#01579b,stroke-width:2px + classDef operatorClass fill:#fff3e0,stroke:#e65100,stroke-width:2px + classDef successClass fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px + classDef failClass fill:#ffcdd2,stroke:#c62828,stroke-width:2px + + class A agentClass + class B,C,D,E operatorClass + class F successClass + class G,H failClass +``` + +1. The init-container signs the AgentCard using the workload's X.509-SVID +2. The operator's X5CProvider validates the `x5c` certificate chain against the SPIRE trust bundle +3. The SPIFFE ID is extracted from the leaf certificate's SAN URI +4. The SPIFFE ID's trust domain is checked against the configured trust domain +5. Both signature AND binding must pass for the `signature-verified=true` label +6. NetworkPolicy enforcement uses this label for traffic control + +--- + +## Configuration + +### Operator-Level Trust Domain + +Set the default trust domain for all AgentCards: + +```bash +--spire-trust-domain=example.org +``` + +### Per-AgentCard Override + +Override the trust domain for a specific AgentCard: + +```yaml +apiVersion: agent.kagenti.dev/v1alpha1 +kind: AgentCard +metadata: + name: weather-agent-card +spec: + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: weather-agent + identityBinding: + trustDomain: partner.example.com # Override operator default + strict: true # Reserved for future audit/enforce distinction +``` + +If `trustDomain` is omitted, the operator-level `--spire-trust-domain` is used. + +### Enforcement Behavior + +When identity binding is configured, binding failures **always** remove the `signature-verified` label from the workload and trigger a restrictive NetworkPolicy (when `--enforce-network-policies=true`). The `strict` field is reserved for future use. + +**Production recommendation:** Always set `strict: true` and `--enforce-network-policies=true`. + +--- + +## Status Fields + +| Field | Description | +|-------|-------------| +| `status.validSignature` | `true` if JWS signature verified via x5c chain | +| `status.signatureSpiffeId` | SPIFFE ID extracted from the leaf certificate SAN | +| `status.signatureIdentityMatch` | `true` when both signature AND binding pass | +| `status.bindingStatus.bound` | `true` if trust domain matches | +| `status.bindingStatus.reason` | `Bound`, `NotBound` | +| `conditions[type=SignatureVerified]` | `True`/`False` with reason | +| `conditions[type=Bound]` | `True`/`False` with binding result | + +--- + +## Troubleshooting + +| Issue | Cause | Fix | +|-------|-------|-----| +| `bindingStatus.bound: false` with trust domain mismatch | SVID issued by a different trust domain | Verify `--spire-trust-domain` matches the SPIRE server's trust domain | +| `bindingStatus` is nil | No `identityBinding` configured | Add `spec.identityBinding` to the AgentCard | +| `signatureIdentityMatch: false` | Signature valid but binding failed | Check trust domain configuration | +| No NetworkPolicy created | `--enforce-network-policies` not set | Enable network policy enforcement | + +```bash +# Debug commands +kubectl get agentcard -o jsonpath='{.status.bindingStatus}' | jq . +kubectl get agentcard -o jsonpath='{.status.signatureSpiffeId}' +kubectl logs -n agentcard-system deployment/agentcard-operator | grep -i binding +kubectl get networkpolicy -n +``` diff --git a/kagenti-operator/docs/agentcard-signature-verification.md b/kagenti-operator/docs/agentcard-signature-verification.md new file mode 100644 index 00000000..4ed5830e --- /dev/null +++ b/kagenti-operator/docs/agentcard-signature-verification.md @@ -0,0 +1,203 @@ +# AgentCard Signature Verification + +**GitHub Issue:** [#116 - Feature: Strict CardSignature Checking](https://github.com/kagenti/kagenti-operator/issues/116) + +This guide covers how AgentCard signature verification works in the kagenti-operator. Agents sign their AgentCards at pod startup using a SPIRE init-container, and the operator verifies signatures using `x5c` certificate chain validation against the SPIRE trust bundle. + +--- + +## How It Works + +1. A **SPIRE init-container** runs before the agent container, fetches an X.509-SVID from the SPIRE Workload API, and signs the AgentCard with the SVID private key +2. The signed card includes an `x5c` header containing the full certificate chain +3. The **operator's X5CProvider** validates the certificate chain against the SPIRE trust bundle, extracts the leaf public key, and verifies the JWS signature +4. The **SPIFFE ID** is extracted from the leaf certificate's SAN URI (cryptographically proven, not self-asserted) +5. **Trust-domain binding** validates that the SPIFFE ID belongs to the configured trust domain + +### Signature Format (JWS) + +```json +{ + "name": "My Agent", + "url": "http://my-agent:8000", + "signatures": [ + { + "protected": "", + "signature": "" + } + ] +} +``` + +The `protected` header contains: +- `alg`: signature algorithm (`RS256`, `ES256`, `ES384`, `ES512`) +- `kid`: SHA-256 fingerprint of the leaf certificate (first 16 hex chars) +- `typ`: `JOSE` +- `x5c`: X.509 certificate chain (leaf-first, standard base64) + +### Verification Steps + +1. Extract `x5c` from the JWS protected header +2. Validate the certificate chain against the SPIRE X.509 trust bundle +3. Validate the leaf certificate has exactly one `spiffe://` SAN URI +4. Extract the leaf public key +5. Create canonical JSON payload (sorted keys, no whitespace, `signatures` excluded) +6. Verify the JWS signature against the leaf public key +7. Extract the SPIFFE ID from the leaf cert SAN for identity binding + +--- + +## Prerequisites + +| Requirement | Notes | +|-------------|-------| +| Kubernetes 1.25+ / OpenShift 4.12+ | Init-containers, emptyDir volumes | +| SPIRE Server + Agent | Deployed cluster-wide (DaemonSet) | +| `spire-controller-manager` | Automates workload registration via `ClusterSPIFFEID` | +| SPIFFE CSI driver | Exposes SPIRE socket to pods | +| Trust bundle in ConfigMap | SPIRE's `BundlePublisher` `k8s_configmap` plugin maintains the trust bundle automatically | + +--- + +## Setup + +### 1. Register Kagenti agents with SPIRE + +Create a `ClusterSPIFFEID` so SPIRE automatically issues SVIDs to agent pods: + +```yaml +apiVersion: spire.spiffe.io/v1alpha1 +kind: ClusterSPIFFEID +metadata: + name: kagenti-agents +spec: + spiffeIDTemplate: "spiffe://{{ .TrustDomain }}/ns/{{ .PodMeta.Namespace }}/sa/{{ .PodSpec.ServiceAccountName }}" + podSelector: + matchLabels: + kagenti.io/type: agent + namespaceSelector: + matchLabels: {} +``` + +### 2. Trust bundle (automatic via SPIRE BundlePublisher) + +The operator reads the SPIRE trust bundle from the ConfigMap that SPIRE's +`BundlePublisher` `k8s_configmap` plugin maintains automatically. No manual +Secret creation is needed — the SPIRE server keeps this ConfigMap up to date +on every CA rotation. + +### 3. Configure the operator + +```bash +--require-a2a-signature=true +--spire-trust-domain= +--spire-trust-bundle-configmap=spire-bundle +--spire-trust-bundle-configmap-namespace=spire-system +``` + +### 4. Deploy an agent with the signing init-container + +See `demos/agentcard-spire-signing/` for complete manifests and a runnable demo. The key elements: + +```yaml +initContainers: + - name: sign-agentcard + image: kagenti/agentcard-signer:latest + env: + - name: SPIFFE_ENDPOINT_SOCKET + value: unix:///run/spire/agent-sockets/spire-agent.sock + - name: UNSIGNED_CARD_PATH + value: /etc/agentcard/agent.json + - name: AGENT_CARD_PATH + value: /app/.well-known/agent.json + - name: SIGN_TIMEOUT + value: "30s" + volumeMounts: + - name: spire-agent-socket + mountPath: /run/spire/agent-sockets + readOnly: true + - name: unsigned-card + mountPath: /etc/agentcard + readOnly: true + - name: signed-card + mountPath: /app/.well-known +``` + +### 5. Create the AgentCard CR + +```yaml +apiVersion: agent.kagenti.dev/v1alpha1 +kind: AgentCard +metadata: + name: weather-agent-card +spec: + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: weather-agent + identityBinding: + strict: true +``` + +### 6. Verify + +```bash +kubectl get agentcard weather-agent-card -o yaml +``` + +Expected status: +- `validSignature: true` +- `signatureSpiffeId: spiffe:///ns//sa/` +- `bindingStatus.bound: true` +- `conditions[type=SignatureVerified].status: True` + +--- + +## Operator Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--require-a2a-signature` | `false` | Require AgentCards to have valid JWS signatures | +| `--spire-trust-domain` | (none) | SPIRE trust domain for binding validation (required) | +| `--spire-trust-bundle-configmap` | (none) | ConfigMap containing the SPIRE trust bundle (SPIFFE JSON) | +| `--spire-trust-bundle-configmap-namespace` | (none) | Namespace of the trust bundle ConfigMap | +| `--spire-trust-bundle-configmap-key` | `bundle.spiffe` | Key in the ConfigMap containing the SPIFFE JSON data | +| `--spire-trust-bundle-refresh-interval` | `5m` | How often to re-read the trust bundle | +| `--svid-expiry-grace-period` | `30m` | How far before SVID expiry to trigger proactive workload restart | +| `--signature-audit-mode` | `false` | Log failures without blocking | +| `--enforce-network-policies` | `false` | Create NetworkPolicies for signature enforcement | + +## Init-Container Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `SPIFFE_ENDPOINT_SOCKET` | `unix:///run/spire/agent-sockets/spire-agent.sock` | SPIRE Workload API socket | +| `UNSIGNED_CARD_PATH` | `/etc/agentcard/agent.json` | Path to read the unsigned card | +| `AGENT_CARD_PATH` | `/app/.well-known/agent.json` | Path to write the signed card | +| `SIGN_TIMEOUT` | `30s` | Timeout for SPIRE connection | + +## Prometheus Metrics + +| Metric | Type | Description | +|--------|------|-------------| +| `kagenti_x5c_chain_validation_total` | Counter | Chain validation attempts (labels: valid, reason) | +| `kagenti_x5c_trust_bundle_age_seconds` | Gauge | Age of the cached trust bundle | +| `kagenti_x5c_trust_bundle_load_errors_total` | Counter | Trust bundle load failures | +| `kagenti_x5c_binding_trust_domain_mismatch_total` | Counter | Trust domain mismatches | + +## Troubleshooting + +| Issue | Cause | Fix | +|-------|-------|-----| +| Init-container `CrashLoopBackOff` | SPIRE socket not mounted or no `ClusterSPIFFEID` matching | Check CSI driver; check `ClusterSPIFFEID` selectors | +| `"No signature verified via x5c chain validation"` | Trust bundle ConfigMap missing or stale | Verify ConfigMap exists with valid SPIFFE JSON | +| `"x5c chain validation failed"` | Cert signed by untrusted CA | Ensure trust bundle matches the SPIRE CA | +| `bindingStatus.bound: false` | Trust domain mismatch | Check `--spire-trust-domain` matches the SVID's trust domain | +| `"x5c header missing"` | Card served without signatures | Verify agent reads from the signed-card emptyDir | + +```bash +# Debug commands +kubectl logs -n -c sign-agentcard # Init-container logs +kubectl get agentcard -o yaml # Full status +kubectl get configmap spire-bundle -n spire-system # Trust bundle exists +``` diff --git a/kagenti-operator/docs/api-reference.md b/kagenti-operator/docs/api-reference.md index 0cc4b652..cab2ba79 100644 --- a/kagenti-operator/docs/api-reference.md +++ b/kagenti-operator/docs/api-reference.md @@ -444,10 +444,12 @@ The `AgentCard` Custom Resource stores agent metadata for dynamic discovery and #### IdentityBinding +Configures workload identity binding for an AgentCard. The SPIFFE ID is extracted from the leaf certificate's SAN URI in the `x5c` chain during signature verification. + | Field | Type | Required | Description | |-------|------|----------|-------------| -| `allowedSpiffeIDs` | []string | Yes | Allowlist of SPIFFE IDs permitted to bind to this agent | -| `strict` | boolean | No | Enable enforcement mode (default: false, audit-only) | +| `trustDomain` | string | No | Overrides the operator-level `--spire-trust-domain` for this AgentCard. If empty, the operator flag value is used. | +| `strict` | boolean | No | Enables enforcement mode: binding failures trigger network isolation. When false (default), results are recorded in status only (audit mode). | ### Status Fields @@ -524,7 +526,7 @@ Represents the A2A agent card structure based on the [A2A specification](https:/ | Field | Type | Description | |-------|------|-------------| -| `protected` | string | Base64url-encoded JWS protected header (contains `alg`, `kid`, `spiffe_id`) | +| `protected` | string | Base64url-encoded JWS protected header (contains `alg`, `kid`, `typ`, `x5c`) | | `signature` | string | Base64url-encoded JWS signature value | | `header` | object | Optional unprotected JWS header parameters (e.g., `timestamp`) | @@ -576,6 +578,24 @@ spec: name: weather-agent ``` +#### AgentCard with Identity Binding + +```yaml +apiVersion: agent.kagenti.dev/v1alpha1 +kind: AgentCard +metadata: + name: weather-agent-card + namespace: default +spec: + syncPeriod: "30s" + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: weather-agent + identityBinding: + strict: true +``` + The AgentCard can also be automatically created by the operator when agent labels are present on the Deployment. #### View Discovered Agents @@ -735,8 +755,8 @@ For Deployments and StatefulSets to be automatically discovered by the operator, ## Additional Resources - [Dynamic Agent Discovery](./dynamic-agent-discovery.md) — How AgentCard enables agent discovery -- [Signature Verification](./a2a-signature-verification.md) — JWS signature verification setup -- [Identity Binding](./identity-binding-quickstart.md) — SPIFFE identity binding guide +- [Signature Verification](./agentcard-signature-verification.md) — JWS signature verification setup +- [Identity Binding](./agentcard-identity-binding.md) — SPIFFE identity binding guide - [Architecture Documentation](./architecture.md) — Operator design and components - [Developer Guide](./dev.md) — Contributing and development - [Getting Started Tutorial](../GETTING_STARTED.md) — Detailed tutorials and examples diff --git a/kagenti-operator/docs/architecture.md b/kagenti-operator/docs/architecture.md index 9a2dc336..90bc1493 100644 --- a/kagenti-operator/docs/architecture.md +++ b/kagenti-operator/docs/architecture.md @@ -78,9 +78,7 @@ The Kagenti Operator is a Kubernetes controller that implements the [Operator Pa - Injects pipeline templates based on mode #### Signature Providers -- **Secret Provider**: Reads public keys from Kubernetes Secrets -- **JWKS Provider**: Fetches public keys from a JWKS endpoint (RFC 7517) -- **NoOp Provider**: Default when signature verification is disabled +- **X5CProvider**: Validates `x5c` certificate chains against the SPIRE X.509 trust bundle and verifies JWS signatures using the leaf public key --- @@ -141,6 +139,11 @@ graph TB CardController -->|Fetches agent card from| Pod end + subgraph "Trust Sources" + TrustBundle[SPIRE Trust Bundle ConfigMap] + SigProvider -->|Validates x5c chain| TrustBundle + end + SyncController -->|Watches| Deployment SyncController -->|Auto-creates| CardCR CardCR -->|targetRef| Deployment @@ -224,11 +227,13 @@ The NetworkPolicy Controller enforces network isolation based on signature verif The operator verifies JWS signatures embedded in agent cards per A2A spec section 8.4: -1. Decode JWS protected header (extract `alg`, `kid`, `spiffe_id`) -2. Validate the algorithm (reject `none`, verify key type matches) -3. Create canonical JSON payload (sorted keys, no whitespace, `signatures` field excluded) -4. Reconstruct signing input: `BASE64URL(protected) || '.' || BASE64URL(canonical_payload)` -5. Verify the cryptographic signature against the public key +1. Extract `x5c` certificate chain from JWS protected header +2. Validate the chain against the SPIRE X.509 trust bundle +3. Extract the SPIFFE ID from the leaf certificate's SAN URI +4. Extract the leaf public key and verify the JWS signature (reject `none`, verify key type matches `alg`) +5. Create canonical JSON payload (sorted keys, no whitespace, `signatures` field excluded) +6. Reconstruct signing input: `BASE64URL(protected) || '.' || BASE64URL(canonical_payload)` +7. Verify the cryptographic signature against the leaf public key Supported algorithms: RS256, RS384, RS512, ES256, ES384, ES512. @@ -236,6 +241,15 @@ Supported algorithms: RS256, RS384, RS512, ES256, ES384, ES512. When `spec.identityBinding` is configured on an AgentCard: +1. The SPIFFE ID is extracted from the leaf certificate's SAN URI (proven by the x5c chain, not self-asserted) +2. The SPIFFE ID's trust domain is validated against the configured trust domain (`spec.identityBinding.trustDomain` or `--spire-trust-domain`) +3. Both signature AND binding must pass for the `signature-verified=true` label +4. NetworkPolicy enforcement uses this label for traffic control + +--- + +## AgentBuild Pipeline Architecture + ``` ConfigMap (step-) └── task-spec.yaml: Complete Tekton TaskSpec @@ -486,7 +500,7 @@ The operator exposes metrics via Prometheus: - [API Reference](./api-reference.md) — CRD specifications - [Dynamic Agent Discovery](./dynamic-agent-discovery.md) — AgentCard discovery system -- [Signature Verification](./a2a-signature-verification.md) — JWS signature setup guide -- [Identity Binding](./identity-binding-quickstart.md) — SPIFFE identity binding guide +- [Signature Verification](./agentcard-signature-verification.md) — JWS signature setup guide +- [Identity Binding](./agentcard-identity-binding.md) — SPIFFE identity binding guide - [Developer Guide](./dev.md) — Contributing to the operator - [Getting Started](../GETTING_STARTED.md) — Tutorials and examples diff --git a/kagenti-operator/docs/dev.md b/kagenti-operator/docs/dev.md index a88a2409..143ab089 100644 --- a/kagenti-operator/docs/dev.md +++ b/kagenti-operator/docs/dev.md @@ -97,7 +97,7 @@ kagenti-operator/ | `api/v1alpha1/` | CRD Go types and schema definitions | | `internal/controller/` | Core reconciliation logic (4 controllers) | | `internal/agentcard/` | Agent card fetching from A2A endpoints | -| `internal/signature/` | JWS signature verification (Secret and JWKS providers) | +| `internal/signature/` | JWS signature verification (x5c provider with SPIRE trust bundle) | | `internal/webhook/` | Admission webhook validation/mutation | | `config/` | Kubernetes manifests and kustomize configs | | `test/` | Test suites and utilities | diff --git a/kagenti-operator/docs/identity-binding-quickstart.md b/kagenti-operator/docs/identity-binding-quickstart.md deleted file mode 100644 index 0e1aeb64..00000000 --- a/kagenti-operator/docs/identity-binding-quickstart.md +++ /dev/null @@ -1,616 +0,0 @@ -# AgentCard → Workload Identity Binding Setup Guide - -**RFC (full design details):** [AgentCard → Workload Identity Binding (Step 1)](https://docs.google.com/document/d/1sOpE9tcw-DlG4Gi31t8e_CV15lNwpZ-NyA2OCDeZZEU/edit?usp=sharing) - -This guide walks you through setting up AgentCard workload identity binding. By the end, you'll have a working system where AgentCards are **bound to workload identities** using SPIFFE IDs embedded in JWS signatures. - -> **Note:** Identity binding works together with signature verification. The SPIFFE ID is extracted from the JWS protected header during signature verification and checked against an allowlist. When both are configured, an agent must pass **both** checks to get network access. - ---- - -## Table of Contents - -1. [Overview](#1-overview) -2. [Prerequisites](#2-prerequisites) -3. [Architecture](#3-architecture) -4. [Setup](#4-setup) -5. [Demo Scenarios](#5-demo-scenarios) -6. [Troubleshooting](#6-troubleshooting) -7. [Reference](#7-reference) -8. [Cleanup](#8-cleanup) - ---- - -## 1. Overview - -**Kagenti Operator** is a Kubernetes operator that manages AI agents following the [A2A Protocol](https://a2a-protocol.org/). Agents discover each other by publishing an **AgentCard** (a JSON document describing the agent's capabilities). - -### What Identity Binding Provides - -| Without Identity Binding | With Identity Binding | -|--------------------------|----------------------| -| Any workload with a valid signature can communicate | Only workloads with matching SPIFFE IDs in the allowlist get network access | - -### Key Features - -- **Cryptographic identity**: SPIFFE ID embedded in the JWS protected header during signing -- **Allowlist enforcement**: Only agents whose SPIFFE ID matches the allowlist are bound -- **NetworkPolicy enforcement**: Network-level isolation when binding fails -- **Operational visibility**: Kubernetes Events and status conditions for monitoring - ---- - -## 2. Prerequisites - -| Tool | Version | Verify | -|------|---------|--------| -| kubectl | v1.28+ | `kubectl version --client` | -| helm | v3.0+ | `helm version` | -| openssl | any | `openssl version` | -| python3 | 3.8+ | `python3 --version` | -| jq | any | `jq --version` | -| Docker/Podman | any | `docker version` | - -**Python packages** (for signing): -```bash -pip3 install cryptography -``` - -**Kubernetes cluster options:** -- **Local:** kind, minikube, k3d, or Docker Desktop -- **Cloud:** EKS, GKE, AKS, OpenShift - -**Clone the repository:** - -```bash -git clone https://github.com/kagenti/kagenti-operator.git -cd kagenti-operator -``` - -> All commands assume you're in the `kagenti-operator` directory. - ---- - -## 3. Architecture - -### High-Level Flow - -```mermaid -flowchart LR - A["Agent\n(serves signed card\nwith SPIFFE ID)"] - B["AgentCard Controller\n(fetches card)"] - C{"Signature\nvalid?"} - D["Extract SPIFFE ID\nfrom JWS header"] - E{"SPIFFE ID\nin allowlist?"} - F["Bound=true\n✅ Network access"] - G["Bound=false\n❌ Network blocked"] - H["Reject card"] - - A -->|"HTTP GET"| B - B --> C - C -->|"Yes"| D - C -->|"No"| H - D --> E - E -->|"Yes"| F - E -->|"No"| G - - classDef agentClass fill:#e1f5ff,stroke:#01579b,stroke-width:2px - classDef operatorClass fill:#fff3e0,stroke:#e65100,stroke-width:2px - classDef successClass fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px - classDef failClass fill:#ffcdd2,stroke:#c62828,stroke-width:2px - - class A agentClass - class B,C,D,E operatorClass - class F successClass - class G,H failClass -``` - -### How SPIFFE ID Binding Works - -The SPIFFE ID used for binding comes **exclusively** from the JWS protected header `spiffe_id` claim, which is cryptographically bound to the signature. This ensures all identity claims are backed by the signing key — no fallback paths, no weaker alternatives. - -**To embed the SPIFFE ID during signing:** -```bash -python3 kagenti-operator/scripts/sign-agent-card.py card.json key.pem \ - --key-id my-key --spiffe-id spiffe://cluster.local/ns/demo/sa/my-sa -``` - -If the card is not signed with `--spiffe-id`, binding fails with a clear error message. - -### SPIFFE ID Format - -``` -spiffe:///ns//sa/ -``` - -**Example:** -``` -spiffe://cluster.local/ns/demo/sa/weather-sa -``` - -### Enforcement Model - -When identity binding is configured alongside signature verification: -- **Both** signature AND binding must pass for the `signature-verified=true` label -- NetworkPolicy uses this label to allow/block inter-agent traffic -- Failed binding → label removed → NetworkPolicy blocks network access - -### Component Responsibilities - -| Component | Code Location | -|-----------|---------------| -| AgentCardReconciler (binding evaluation) | `internal/controller/agentcard_controller.go` | -| Signature Verifier (extracts SPIFFE ID) | `internal/signature/verifier.go` | -| NetworkPolicy Controller | `internal/controller/agentcard_networkpolicy_controller.go` | -| Signing Script | `scripts/sign-agent-card.py` | - ---- - -## 4. Setup - -### Step 1: Install Dependencies - -```bash -# Create cluster (skip if you have one) -kind create cluster --name kagenti-demo - -# Install cert-manager (for webhook certificates) -kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.yaml -kubectl wait --for=condition=Available deployment/cert-manager -n cert-manager --timeout=120s -``` - -### Step 2: Generate Keys and Create Secret - -```bash -# Generate RSA key pair -openssl genrsa -out private-key.pem 2048 -openssl rsa -in private-key.pem -pubout -out public-key.pem - -# Create namespace and secret -kubectl create namespace kagenti-system -kubectl label namespace kagenti-system control-plane=kagenti-operator -kubectl create secret generic a2a-public-keys \ - --from-file=public.pem=public-key.pem \ - --from-file=my-signing-key=public-key.pem \ - --namespace=kagenti-system -``` - -> ⚠️ **Security:** Keep `private-key.pem` secure. Never commit it to git. - -### Step 3: Install Kagenti Operator - -```bash -# Build the operator (runs in a subshell so we stay in the repo root) -(cd kagenti-operator && make docker-build IMG=kagenti-operator:dev) -kind load docker-image kagenti-operator:dev --name kagenti-demo - -kubectl create namespace kagenti-system 2>/dev/null || true -helm install kagenti-operator charts/kagenti-operator \ - --namespace kagenti-system \ - --set signatureVerification.enabled=true \ - --set signatureVerification.provider=secret \ - --set signatureVerification.secret.name=a2a-public-keys \ - --set signatureVerification.secret.namespace=kagenti-system \ - --set signatureVerification.enforceNetworkPolicies=true \ - --set controllerManager.container.image.repository=kagenti-operator \ - --set controllerManager.container.image.tag=dev \ - --set controllerManager.container.cmd=/manager - -# Verify -kubectl wait --for=condition=Available deployment/kagenti-controller-manager \ - -n kagenti-system --timeout=120s -kubectl logs -n kagenti-system deployment/kagenti-controller-manager | head -20 -``` - -> **Note:** `--set controllerManager.container.cmd=/manager` is required for locally-built images. Production releases use `/ko-app/cmd`. - -### Step 4: Sign and Deploy Agent - -```bash -kubectl create namespace demo -kubectl create serviceaccount weather-sa -n demo - -# Create the agent card JSON -cat > weather-agent-card.json << 'EOF' -{ - "name": "Weather Agent", - "description": "Provides weather information for any location", - "version": "1.0.0", - "url": "http://weather-agent.demo.svc.cluster.local:8000", - "capabilities": {"streaming": true, "pushNotifications": false}, - "defaultInputModes": ["text/plain"], - "defaultOutputModes": ["application/json"] -} -EOF - -# Sign with private key + embed SPIFFE ID in JWS protected header -python3 kagenti-operator/scripts/sign-agent-card.py weather-agent-card.json private-key.pem \ - --key-id my-signing-key \ - --spiffe-id spiffe://cluster.local/ns/demo/sa/weather-sa \ - --output signed-weather-card.json - -# Create ConfigMap from signed card -cat > weather-configmap.yaml << EOF -apiVersion: v1 -kind: ConfigMap -metadata: - name: weather-agent-card - namespace: demo -data: - agent.json: | -$(cat signed-weather-card.json | sed 's/^/ /') -EOF - -kubectl apply -f weather-configmap.yaml - -# Deploy Deployment + Service + AgentCard (no legacy Agent CRD) -cat < --sort-by='.lastTimestamp' | grep -i binding - -# Check full AgentCard status -kubectl get agentcard -n -o yaml - -# Check pod labels -kubectl get pods -n -l --show-labels - -# Check NetworkPolicies -kubectl get networkpolicy -n -``` - ---- - -## 7. Reference - -### AgentCard Identity Binding Fields - -```yaml -spec: - targetRef: - apiVersion: apps/v1 - kind: Deployment - name: my-agent - identityBinding: - allowedSpiffeIDs: # Required - - "spiffe://cluster.local/ns/demo/sa/my-sa" -``` - -### Signing with SPIFFE ID - -```bash -# Embed SPIFFE ID in JWS protected header (required for identity binding) -python3 kagenti-operator/scripts/sign-agent-card.py card.json private-key.pem \ - --key-id my-key \ - --spiffe-id spiffe://cluster.local/ns/demo/sa/my-sa -``` - -### Required Deployment Labels - -```yaml -metadata: - labels: - kagenti.io/type: agent # Required — identifies this as an agent workload - kagenti.io/protocol: a2a # Required — protocol for card fetching -``` - -### Status Fields - -| Field | Description | -|-------|-------------| -| `status.validSignature` | `true` if JWS signature verified | -| `status.signatureSpiffeId` | SPIFFE ID extracted from JWS protected header (if present and signature valid) | -| `status.signatureIdentityMatch` | `true` only when BOTH signature AND binding pass | -| `status.expectedSpiffeID` | SPIFFE ID used for binding evaluation (from JWS protected header) | -| `status.bindingStatus.bound` | `true` if SPIFFE ID in allowlist | -| `status.bindingStatus.reason` | `Bound`, `NotBound`, `WorkloadNotFound` | -| `conditions[type=SignatureVerified]` | `True`/`False` with reason | -| `conditions[type=Bound]` | `True`/`False` with binding result | - -### Helm Values for Identity Binding - -Identity binding requires signature verification to be enabled for enforcement: - -| Parameter | Description | Required | -|-----------|-------------|----------| -| `signatureVerification.enabled` | Enable signature verification | Yes | -| `signatureVerification.provider` | `secret` or `jwks` | Yes | -| `signatureVerification.secret.name` | Secret with public keys | Yes (if provider=secret) | -| `signatureVerification.secret.namespace` | Secret namespace | Yes (if provider=secret) | -| `signatureVerification.enforceNetworkPolicies` | Enable NetworkPolicy enforcement | Recommended | - -### Kubernetes Events - -| Event | Description | -|-------|-------------| -| `BindingEvaluated` | Binding check passed | -| `BindingFailed` | SPIFFE ID not in allowlist or no SPIFFE ID in JWS header | -| `SignatureEvaluated` | Signature verified successfully | -| `SignatureFailed` | Signature verification failed | - -### Controller Ownership - -| Controller | Responsibilities | -|------------|------------------| -| AgentCard Controller | Verify signature, extract SPIFFE ID, evaluate binding, propagate labels | -| NetworkPolicy Controller | Create permissive/restrictive policies based on `signature-verified` label | - ---- - -## 8. Cleanup - -```bash -kubectl delete namespace demo -helm uninstall kagenti-operator -n kagenti-system -kubectl delete secret a2a-public-keys -n kagenti-system -kubectl delete namespace kagenti-system -kind delete cluster --name kagenti-demo -rm -f private-key.pem public-key.pem \ - weather-agent-card.json signed-weather-card.json weather-configmap.yaml -``` - ---- - -## Summary - -| What You Built | Description | -|----------------|-------------| -| Cryptographic identity binding | SPIFFE IDs from JWS headers verified against allowlist | -| Single identity source | JWS protected header only — all identity claims cryptographically bound | -| NetworkPolicy enforcement | Network-level isolation when binding or signature fails | -| Automatic restoration | Correct the allowlist → binding passes → access restored | - -**Production recommendations:** -- Always sign with `--spiffe-id` to embed the identity in the JWS protected header -- Enable `enforceNetworkPolicies` for network-level enforcement -- Set up Prometheus alerts on binding failures -- When SPIRE integration lands (Step 2), the init container will automate signing with real SVIDs diff --git a/kagenti-operator/docs/images/kagenti-build-pipeline-design.png b/kagenti-operator/docs/images/kagenti-build-pipeline-design.png deleted file mode 100644 index b58055a6..00000000 Binary files a/kagenti-operator/docs/images/kagenti-build-pipeline-design.png and /dev/null differ diff --git a/kagenti-operator/docs/images/kagenti-build-pipeline.png b/kagenti-operator/docs/images/kagenti-build-pipeline.png deleted file mode 100644 index e473843d..00000000 Binary files a/kagenti-operator/docs/images/kagenti-build-pipeline.png and /dev/null differ diff --git a/kagenti-operator/docs/images/kagenti-identity-binding-architecture.png b/kagenti-operator/docs/images/kagenti-identity-binding-architecture.png deleted file mode 100644 index 364f39a6..00000000 Binary files a/kagenti-operator/docs/images/kagenti-identity-binding-architecture.png and /dev/null differ diff --git a/kagenti-operator/docs/jwks_guide.md b/kagenti-operator/docs/jwks_guide.md deleted file mode 100644 index 6a990b74..00000000 --- a/kagenti-operator/docs/jwks_guide.md +++ /dev/null @@ -1,432 +0,0 @@ -# JWKS Provider Setup Guide - -Switch from Kubernetes Secrets to JWKS (JSON Web Key Set) for signature verification. - -> **Prerequisites:** Complete the [A2A Signature Verification Quick Start](../../kagenti-operator/docs/a2a-signature-verification.md#quick-start) first. You should have kagenti-operator running with a weather-agent. - ---- - -## What You'll Learn - -This guide shows two ways to provide public keys for signature verification: - -| Approach | When to Use | What You'll Do | -|----------|-------------|----------------| -| **Local JWKS Server** | Development, testing | Follow all 8 steps below | -| **External IdP** | Production | Skip to "Production Setup" at the end | - -### Architecture Overview - -```mermaid -flowchart LR - subgraph setup["1. Setup Phase"] - direction TB - A[Generate Keys
generate_jwks.py] - B[private-key.pem
for signing] - C[jwks.json
public keys] - A --> B - A --> C - end - - subgraph deploy["2. Deploy to Kubernetes"] - direction TB - D[ConfigMap
jwks-keys] - E[JWKS Server
/.well-known/jwks.json] - D --> E - end - - subgraph runtime["3. Runtime Verification"] - direction TB - F[Kagenti Operator] - G[Agent Pod
Signed AgentCard] - F -->|Fetch Public Keys| E - F -->|Verify Signature| G - end - - C -->|kubectl create| D - B -.->|Sign AgentCard| G - - style setup fill:#e1f5ff,stroke:#01579b,stroke-width:2px - style deploy fill:#fff3e0,stroke:#e65100,stroke-width:2px - style runtime fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px -``` - -**How It Works:** -1. **Setup** (Steps 1-2): Generate RSA keys - private key signs AgentCards, public key goes in JWKS -2. **Deploy** (Steps 3-4): Deploy JWKS server in Kubernetes that serves your public keys -3. **Configure** (Step 6): Point operator to JWKS server instead of Kubernetes Secret -4. **Verify** (Steps 7-8): Sign and deploy agent, confirm signature verification works - ---- - -## Local JWKS Setup - -### Step 1: Install Python Library - -```bash -pip3 install --user jwcrypto -``` - ---- - -### Step 2: Generate Keys - -This creates a private key for signing and a JWKS file with the public key: - -```bash -cat > generate_jwks.py << 'EOF' -#!/usr/bin/env python3 -import json -import argparse -from jwcrypto import jwk - -def generate_jwks(key_id, key_size=2048): - key = jwk.JWK.generate(kty='RSA', size=key_size) - private_pem = key.export_to_pem(private_key=True, password=None) - public_jwk = json.loads(key.export_public()) - public_jwk.update({'use': 'sig', 'alg': 'RS256', 'kid': key_id}) - - with open('private-key.pem', 'wb') as f: - f.write(private_pem) - with open('jwks.json', 'w') as f: - json.dump({"keys": [public_jwk]}, f, indent=2) - - print(f"✅ Generated keys with ID: {key_id}") - print(f" private-key.pem - Use to sign AgentCards") - print(f" jwks.json - Deploy to Kubernetes") - -if __name__ == '__main__': - parser = argparse.ArgumentParser() - parser.add_argument('--key-id', default='production-key', help='Key ID') - args = parser.parse_args() - generate_jwks(args.key_id) -EOF - -python3 generate_jwks.py --key-id production-2025 -``` - ---- - -### Step 3: Store JWKS in Kubernetes - -Upload the public key file to a ConfigMap: - -```bash -kubectl create configmap jwks-keys \ - --from-file=jwks.json \ - -n kagenti-system \ - --dry-run=client -o yaml | kubectl apply -f - -``` - ---- - -### Step 4: Deploy JWKS Server - -This server exposes your public keys at the standard `/.well-known/jwks.json` endpoint: - -```bash -# Create server -cat > jwks_server.py << 'EOF' -#!/usr/bin/env python3 -import json, os -from flask import Flask, jsonify - -app = Flask(__name__) -JWKS_FILE = "/etc/jwks-data/jwks.json" -JWKS_DATA = {"keys": []} - -def load_jwks(): - global JWKS_DATA - if os.path.exists(JWKS_FILE): - with open(JWKS_FILE, 'r') as f: - JWKS_DATA = json.load(f) - print(f"✅ Loaded {len(JWKS_DATA.get('keys', []))} key(s)") - -@app.route('/.well-known/jwks.json') -def jwks(): - return jsonify(JWKS_DATA) - -@app.route('/health') -def health(): - return jsonify({"status": "healthy", "keys": len(JWKS_DATA.get('keys', []))}), 200 - -if __name__ == '__main__': - load_jwks() - app.run(host='0.0.0.0', port=8080) -EOF - -# Create Dockerfile -cat > Dockerfile << 'EOF' -FROM python:3.9-slim -WORKDIR /app -RUN pip install --no-cache-dir flask -COPY jwks_server.py . -EXPOSE 8080 -CMD ["python3", "jwks_server.py"] -EOF - -# Create deployment -cat > jwks-deployment.yaml << 'EOF' -apiVersion: v1 -kind: Service -metadata: - name: jwks-server - namespace: kagenti-system -spec: - selector: - app: jwks-server - ports: - - port: 80 - targetPort: 8080 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: jwks-server - namespace: kagenti-system -spec: - replicas: 1 - selector: - matchLabels: - app: jwks-server - template: - metadata: - labels: - app: jwks-server - spec: - containers: - - name: jwks-server - image: jwks-server:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8080 - volumeMounts: - - name: jwks-data - mountPath: /etc/jwks-data - readOnly: true - readinessProbe: - httpGet: - path: /health - port: 8080 - initialDelaySeconds: 3 - periodSeconds: 5 - volumes: - - name: jwks-data - configMap: - name: jwks-keys -EOF - -# Build and deploy -docker build -t jwks-server:latest . -kind load docker-image jwks-server:latest --name agent-platform -kubectl apply -f jwks-deployment.yaml - -# Wait for ready -kubectl wait --for=condition=Ready pod -l app=jwks-server -n kagenti-system --timeout=60s -echo "✅ JWKS server deployed" -``` - ---- - -### Step 5: Test JWKS Endpoint - -Verify the server is working and serving your public key: - -```bash -kubectl run test-jwks --rm -i --restart=Never --image=curlimages/curl -- \ - curl -s http://jwks-server.kagenti-system.svc.cluster.local/.well-known/jwks.json -``` - -✅ You should see JSON output with your `production-2025` key - ---- - -### Step 6: Switch Operator to JWKS - -Reconfigure the operator to fetch keys from your JWKS server instead of the Secret: - -```bash -helm upgrade kagenti-operator ./charts/kagenti-operator \ - --namespace kagenti-system \ - --reuse-values \ - --set signatureVerification.provider=jwks \ - --set signatureVerification.jwks.url=http://jwks-server.kagenti-system.svc.cluster.local/.well-known/jwks.json - -kubectl rollout status deployment/kagenti-controller-manager -n kagenti-system - -# Important: Restart operator to immediately use JWKS (bypasses 5-minute cache) -kubectl rollout restart deployment/kagenti-controller-manager -n kagenti-system -kubectl rollout status deployment/kagenti-controller-manager -n kagenti-system - -echo "✅ Operator switched to JWKS" -``` - ---- - -### Step 7: Sign and Update Agent - -Now sign your AgentCard with the new private key and update the deployment: - -```bash -# Sign your AgentCard and save to file -python3 << 'EOF' -import json, base64, copy -from datetime import datetime -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import padding -from cryptography.hazmat.backends import default_backend - -agent_card = { - "name": "Weather Agent", - "description": "Provides weather information", - "version": "1.0.0", - "url": "http://weather-agent-svc.default.svc.cluster.local:8000", - "capabilities": {"streaming": True} -} - -with open('private-key.pem', 'rb') as f: - private_key = serialization.load_pem_private_key(f.read(), password=None, backend=default_backend()) - -canonical = json.dumps(agent_card, sort_keys=True, separators=(',', ':'), ensure_ascii=False) -signature = private_key.sign(canonical.encode('utf-8'), padding.PKCS1v15(), hashes.SHA256()) - -signed = copy.deepcopy(agent_card) -signed['signature'] = { - 'algorithm': 'RS256', - 'keyId': 'production-2025', - 'value': base64.b64encode(signature).decode('utf-8'), - 'timestamp': datetime.utcnow().isoformat() + 'Z' -} - -# Save to file for kubectl -with open('signed-agent.json', 'w') as f: - json.dump(signed, f, indent=2) - -print("✅ Signed AgentCard saved to signed-agent.json") -print(json.dumps(signed, indent=2)) -EOF - -# Update the ConfigMap with the new signature -kubectl create configmap weather-agent-card \ - --from-file=agent.json=signed-agent.json \ - -n default \ - --dry-run=client -o yaml | kubectl apply -f - - -# Restart deployment to pick up new signature -kubectl rollout restart deployment weather-agent -n default - -# Wait for update -sleep 15 -echo "✅ Agent updated with new signature" -rm -f signed-agent.json -``` - ---- - -### Step 8: Verify Signature Verification - -Check that the operator successfully verified your agent's signature using JWKS: - -```bash -kubectl get agentcard -n default -o json | jq '.items[0] | { - name: .metadata.name, - validSignature: .status.validSignature, - keyId: .status.signatureKeyId, - synced: (.status.conditions[] | select(.type=="Synced") | .status) -}' -``` - -Expected output: -```json -{ - "name": "weather-agent-card", - "validSignature": true, - "keyId": "production-2025", - "synced": "True" -} -``` - -✅ **Success!** Your operator is now using JWKS for signature verification. - ---- - -## Production Setup - -### Using External Identity Providers - -For production, skip Steps 2-5 (no local JWKS server needed). Just configure the operator to point to your identity provider's JWKS endpoint: - -**Auth0:** -```bash -helm upgrade kagenti-operator ./charts/kagenti-operator \ - --reuse-values \ - --set signatureVerification.provider=jwks \ - --set signatureVerification.jwks.url=https://YOUR_DOMAIN.auth0.com/.well-known/jwks.json -``` - -**Keycloak:** -```bash -helm upgrade kagenti-operator ./charts/kagenti-operator \ - --reuse-values \ - --set signatureVerification.provider=jwks \ - --set signatureVerification.jwks.url=https://keycloak.example.com/realms/YOUR_REALM/protocol/openid-connect/certs -``` - -**Okta:** -```bash -helm upgrade kagenti-operator ./charts/kagenti-operator \ - --reuse-values \ - --set signatureVerification.provider=jwks \ - --set signatureVerification.jwks.url=https://YOUR_DOMAIN.okta.com/oauth2/default/v1/keys -``` - -**Benefits:** -- ✅ Automatic key rotation -- ✅ No manual key management -- ✅ Secure HTTPS endpoints -- ✅ Enterprise-grade infrastructure - ---- - -## Troubleshooting - -### Issue: JWKS server pod not starting - -**Check server logs:** -```bash -kubectl logs -n kagenti-system -l app=jwks-server -``` - -**Verify ConfigMap exists:** -```bash -kubectl get configmap jwks-keys -n kagenti-system -o yaml -``` - ---- - -### Issue: Signature verification still failing - -**Check key ID mismatch:** -```bash -# What key ID is in your JWKS? -kubectl run check-jwks --rm -i --image=curlimages/curl -- \ - curl -s http://jwks-server.kagenti-system.svc.cluster.local/.well-known/jwks.json - -# What key ID did you use to sign? -kubectl get agentcard -n default -o jsonpath='{.items[0].status.signatureKeyId}' -``` - -**Solution:** Both must match (e.g., `production-2025`) - ---- - -### Issue: Operator not fetching from JWKS - -**Check operator configuration:** -```bash -kubectl logs -n kagenti-system deployment/kagenti-controller-manager | grep -i jwks -``` - -**Look for:** "Using JWKS provider" or similar message - -**If missing:** Re-run Step 6 to reconfigure the operator \ No newline at end of file diff --git a/kagenti-operator/docs/operator.md b/kagenti-operator/docs/operator.md index c087a8ac..98c1fc84 100644 --- a/kagenti-operator/docs/operator.md +++ b/kagenti-operator/docs/operator.md @@ -10,10 +10,12 @@ The `AgentBuild` CR defines the specifications for building and publishing a con * Follow a Deployment-first model where users create standard Kubernetes Deployments/StatefulSets for their agents * Provide dynamic agent discovery through `AgentCard` CRs with `targetRef`-based workload binding +* Provide cryptographic signature verification for agent cards (JWS with RSA/ECDSA via x5c certificate chains) +* Support SPIFFE-based workload identity binding with trust-domain validation +* Enforce network isolation via Kubernetes NetworkPolicies based on verification status * Automate the container image building and publishing process for AI agents triggered by `AgentBuild` CRs * Integrate with Tekton Pipelines for the image building workflow, consisting of pull, build, and push tasks * Securely manage GitHub repository access using a referenced Kubernetes Secret -* Support A2A agent card signature verification and identity binding * Support cluster-wide as well as namespaced scope deployment ## Deployment Modes @@ -95,12 +97,10 @@ Watches AgentCard resources when `--enforce-network-policies` is enabled. Create ## Security Features ### Signature Verification -The operator verifies JWS signatures embedded in agent cards per A2A spec section 8.4. Two providers are supported: -- **Secret Provider**: Reads public keys from Kubernetes Secrets -- **JWKS Provider**: Fetches public keys from a JWKS endpoint (RFC 7517) +The operator verifies JWS signatures embedded in agent cards per A2A spec section 8.4 using the **X5CProvider**. The `x5c` certificate chain in the JWS protected header is validated against the SPIRE X.509 trust bundle, and the leaf certificate's public key is used for signature verification. ### Identity Binding -AgentCards can be configured with `spec.identityBinding.allowedSpiffeIDs` to restrict which workload identities are permitted. The SPIFFE ID is extracted from the JWS protected header during signature verification. +AgentCards are bound to workload identities via trust-domain validation. The SPIFFE ID is extracted from the leaf certificate's SAN URI (cryptographically proven by the x5c chain) and validated against the configured trust domain. ### Network Isolation When `--enforce-network-policies` is enabled, the NetworkPolicy controller creates: diff --git a/kagenti-operator/go.mod b/kagenti-operator/go.mod index b5608b96..0cb69229 100644 --- a/kagenti-operator/go.mod +++ b/kagenti-operator/go.mod @@ -1,6 +1,6 @@ module github.com/kagenti/operator -go 1.23.0 +go 1.24.0 godebug default=go1.23 @@ -8,7 +8,7 @@ require ( github.com/onsi/ginkgo/v2 v2.21.0 github.com/onsi/gomega v1.35.1 github.com/prometheus/client_golang v1.19.1 - golang.org/x/sync v0.8.0 + github.com/spiffe/go-spiffe/v2 v2.5.0 k8s.io/api v0.32.0 k8s.io/apimachinery v0.32.0 k8s.io/client-go v0.32.0 @@ -17,7 +17,8 @@ require ( ) require ( - cel.dev/expr v0.18.0 // indirect + cel.dev/expr v0.24.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -31,7 +32,8 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -43,7 +45,7 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.22.0 // indirect github.com/google/gnostic-models v0.6.8 // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect github.com/google/uuid v1.6.0 // indirect @@ -63,29 +65,33 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/zeebo/errs v1.4.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect - go.opentelemetry.io/otel v1.28.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect - go.opentelemetry.io/otel/metric v1.28.0 // indirect - go.opentelemetry.io/otel/sdk v1.28.0 // indirect - go.opentelemetry.io/otel/trace v1.28.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.39.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/net v0.30.0 // indirect - golang.org/x/oauth2 v0.23.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/term v0.25.0 // indirect - golang.org/x/text v0.19.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/text v0.26.0 // indirect golang.org/x/time v0.7.0 // indirect - golang.org/x/tools v0.26.0 // indirect + golang.org/x/tools v0.33.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect - google.golang.org/grpc v1.67.0 // indirect - google.golang.org/protobuf v1.35.1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect + google.golang.org/grpc v1.75.0 // indirect + google.golang.org/protobuf v1.36.7 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/kagenti-operator/go.sum b/kagenti-operator/go.sum index 7cff3ac3..aec43282 100644 --- a/kagenti-operator/go.sum +++ b/kagenti-operator/go.sum @@ -1,5 +1,7 @@ -cel.dev/expr v0.18.0 h1:CJ6drgk+Hf96lkLikr4rFf19WrU0BOWEihyZnI2TAzo= -cel.dev/expr v0.18.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= @@ -30,9 +32,11 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= @@ -58,8 +62,8 @@ github.com/google/cel-go v0.22.0/go.mod h1:BuznPXXfQDpXKWQ9sPW3TzlAJN5zzFe+i9tIs github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -110,13 +114,15 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= +github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -126,26 +132,32 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= -go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= -go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ= -go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= -go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= -go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE= -go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg= -go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= -go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -157,6 +169,8 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -165,48 +179,50 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= -golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= -golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= -golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= -golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= -golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw= -google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= -google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw= -google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU= +google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/kagenti-operator/internal/agentcard/fetcher.go b/kagenti-operator/internal/agentcard/fetcher.go index 7ab89b93..01e209f6 100644 --- a/kagenti-operator/internal/agentcard/fetcher.go +++ b/kagenti-operator/internal/agentcard/fetcher.go @@ -28,28 +28,22 @@ import ( ctrl "sigs.k8s.io/controller-runtime" ) -var ( - fetcherLogger = ctrl.Log.WithName("agentcard").WithName("fetcher") -) +var fetcherLogger = ctrl.Log.WithName("agentcard").WithName("fetcher") const ( - // A2A protocol constants A2AProtocol = "a2a" A2AAgentCardPath = "/.well-known/agent.json" DefaultFetchTimeout = 10 * time.Second ) -// Fetcher handles fetching agent cards from various protocols type Fetcher interface { Fetch(ctx context.Context, protocol string, serviceURL string) (*agentv1alpha1.AgentCardData, error) } -// DefaultFetcher implements the Fetcher interface type DefaultFetcher struct { httpClient *http.Client } -// NewFetcher creates a new agent card fetcher func NewFetcher() Fetcher { return &DefaultFetcher{ httpClient: &http.Client{ @@ -58,7 +52,6 @@ func NewFetcher() Fetcher { } } -// Fetch retrieves an agent card based on the protocol func (f *DefaultFetcher) Fetch(ctx context.Context, protocol string, serviceURL string) (*agentv1alpha1.AgentCardData, error) { switch protocol { case A2AProtocol: @@ -68,41 +61,34 @@ func (f *DefaultFetcher) Fetch(ctx context.Context, protocol string, serviceURL } } -// fetchA2ACard fetches an A2A agent card from the well-known endpoint func (f *DefaultFetcher) fetchA2ACard(ctx context.Context, serviceURL string) (*agentv1alpha1.AgentCardData, error) { - // Construct the agent card URL agentCardURL := serviceURL + A2AAgentCardPath fetcherLogger.Info("Fetching A2A agent card", "url", agentCardURL) - // Create the HTTP request req, err := http.NewRequestWithContext(ctx, http.MethodGet, agentCardURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - - // Set headers req.Header.Set("Accept", "application/json") - // Execute the request resp, err := f.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch agent card: %w", err) } defer resp.Body.Close() - // Check response status + const maxCardSize = 1 << 20 // 1 MiB + if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, _ := io.ReadAll(io.LimitReader(resp.Body, maxCardSize)) return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) } - // Read and parse the response - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxCardSize)) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } - // Parse the agent card var agentCardData agentv1alpha1.AgentCardData if err := json.Unmarshal(body, &agentCardData); err != nil { return nil, fmt.Errorf("failed to parse agent card JSON: %w", err) @@ -116,10 +102,6 @@ func (f *DefaultFetcher) fetchA2ACard(ctx context.Context, serviceURL string) (* return &agentCardData, nil } -// GetServiceURL constructs the service URL for an agent -// Following the pattern from agent_controller.go where services are named func GetServiceURL(agentName, namespace string, port int32) string { - // Use cluster DNS for service discovery - // Format: http://..svc.cluster.local: return fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", agentName, namespace, port) } diff --git a/kagenti-operator/internal/controller/agentcard_controller.go b/kagenti-operator/internal/controller/agentcard_controller.go index 9b308198..4161be20 100644 --- a/kagenti-operator/internal/controller/agentcard_controller.go +++ b/kagenti-operator/internal/controller/agentcard_controller.go @@ -23,6 +23,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" appsv1 "k8s.io/api/apps/v1" @@ -40,9 +41,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" - "sigs.k8s.io/controller-runtime/pkg/reconcile" agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" "github.com/kagenti/operator/internal/agentcard" @@ -50,30 +51,37 @@ import ( ) const ( - // Label keys LabelAgentType = "kagenti.io/type" - LabelAgentProtocol = "kagenti.io/agent-protocol" // Legacy label - LabelKagentiProtocol = "kagenti.io/protocol" // New label + LabelAgentProtocol = "kagenti.io/agent-protocol" // deprecated + LabelKagentiProtocol = "kagenti.io/protocol" + LabelValueAgent = "agent" - // Label values - LabelValueAgent = "agent" - - // LabelSignatureVerified indicates if an agent's signature has been verified. - // Used by NetworkPolicy rules to allow traffic between verified agents. + // LabelSignatureVerified is used by NetworkPolicy rules to gate traffic between verified agents. LabelSignatureVerified = "agent.kagenti.dev/signature-verified" - // Finalizer + // Deprecated: superseded by AnnotationVerifiedStatePrefix. Kept for cleanup on existing workloads. + AnnotationLastVerifiedState = "agent.kagenti.dev/last-verified-state" + + // AnnotationVerifiedStatePrefix stores per-card verified state on the workload. + // Multiple cards targeting the same workload are AND-aggregated for the label. + AnnotationVerifiedStatePrefix = "verified-state.agent.kagenti.dev/" + + // AnnotationResignTrigger is patched onto the pod template to trigger a rolling restart + // when the operator detects that the signing SVID is expiring or the CA has rotated. + AnnotationResignTrigger = "agentcard.kagenti.dev/resign-trigger" + + // AnnotationBundleHash records the trust bundle hash at the time of the last signing. + AnnotationBundleHash = "agentcard.kagenti.dev/bundle-hash" + AgentCardFinalizer = "agentcard.kagenti.dev/finalizer" + DefaultSyncPeriod = 30 * time.Second - // Default sync period - DefaultSyncPeriod = 30 * time.Second + DefaultSVIDExpiryGracePeriod = 30 * time.Minute - // Binding status reasons ReasonBound = "Bound" ReasonNotBound = "NotBound" ReasonAgentNotFound = "AgentNotFound" - // Signature verification reasons ReasonSignatureValid = "SignatureValid" ReasonSignatureInvalid = "SignatureInvalid" ReasonSignatureInvalidAudit = "SignatureInvalidAudit" @@ -82,14 +90,10 @@ const ( var ( agentCardLogger = ctrl.Log.WithName("controller").WithName("AgentCard") - // ErrWorkloadNotFound indicates the referenced workload does not exist ErrWorkloadNotFound = errors.New("workload not found") - - // ErrNotAgentWorkload indicates the workload doesn't have required agent labels ErrNotAgentWorkload = errors.New("resource is not a Kagenti agent") ) -// WorkloadInfo contains information about a discovered agent workload type WorkloadInfo struct { Name string Namespace string @@ -100,7 +104,6 @@ type WorkloadInfo struct { ServiceName string } -// AgentCardReconciler reconciles an AgentCard object type AgentCardReconciler struct { client.Client Scheme *runtime.Scheme @@ -108,10 +111,16 @@ type AgentCardReconciler struct { AgentFetcher agentcard.Fetcher - // Signature verification SignatureProvider signature.Provider RequireSignature bool SignatureAuditMode bool + + // SpireTrustDomain can be overridden per-AgentCard via spec.identityBinding.trustDomain. + SpireTrustDomain string + + // SVIDExpiryGracePeriod controls how far before the leaf cert expires the operator + // triggers a proactive workload restart. Defaults to DefaultSVIDExpiryGracePeriod. + SVIDExpiryGracePeriod time.Duration } // +kubebuilder:rbac:groups=agent.kagenti.dev,resources=agentcards,verbs=get;list;watch;create;update;patch;delete @@ -120,7 +129,7 @@ type AgentCardReconciler struct { // +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;update;patch // +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;update;patch -// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch +// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch func (r *AgentCardReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { agentCardLogger.V(1).Info("Reconciling AgentCard", "namespacedName", req.NamespacedName) @@ -131,92 +140,90 @@ func (r *AgentCardReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, client.IgnoreNotFound(err) } - // Handle deletion if !agentCard.ObjectMeta.DeletionTimestamp.IsZero() { return r.handleDeletion(ctx, agentCard) } - // Add finalizer if !controllerutil.ContainsFinalizer(agentCard, AgentCardFinalizer) { controllerutil.AddFinalizer(agentCard, AgentCardFinalizer) if err := r.Update(ctx, agentCard); err != nil { - agentCardLogger.Error(err, "Unable to add finalizer to AgentCard") + agentCardLogger.Error(err, "Failed to add finalizer to AgentCard") return ctrl.Result{}, err } return ctrl.Result{}, nil } - // Get workload via targetRef workload, err := r.getWorkload(ctx, agentCard) if err != nil { agentCardLogger.Error(err, "Failed to get workload", "agentCard", agentCard.Name) - // Determine the appropriate reason based on error type - var reason, message, conditionReason string - if errors.Is(err, ErrWorkloadNotFound) { - reason = ReasonAgentNotFound + var message, conditionReason string + switch { + case errors.Is(err, ErrWorkloadNotFound): message = "No matching workload found" conditionReason = "WorkloadNotFound" - } else if errors.Is(err, ErrNotAgentWorkload) { - reason = ReasonAgentNotFound + case errors.Is(err, ErrNotAgentWorkload): message = "Referenced resource is not an agent" conditionReason = "NotAgentWorkload" - } else { - reason = ReasonAgentNotFound + default: message = err.Error() conditionReason = "WorkloadError" } - r.updateCondition(ctx, agentCard, "Synced", metav1.ConditionFalse, conditionReason, err.Error()) + if condErr := r.updateCondition(ctx, agentCard, "Synced", metav1.ConditionFalse, conditionReason, err.Error()); condErr != nil { + return ctrl.Result{}, condErr + } - // If identity binding is configured, update binding status if agentCard.Spec.IdentityBinding != nil { - r.updateBindingStatus(ctx, agentCard, false, reason, message, "") + if bindErr := r.updateBindingStatus(ctx, agentCard, false, ReasonAgentNotFound, message, ""); bindErr != nil { + return ctrl.Result{}, bindErr + } if r.Recorder != nil { - r.Recorder.Event(agentCard, corev1.EventTypeWarning, reason, message) + r.Recorder.Event(agentCard, corev1.EventTypeWarning, ReasonAgentNotFound, message) } } return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil } - // Check if workload is ready if !workload.Ready { agentCardLogger.Info("Workload not ready yet, skipping sync", "workload", workload.Name, "kind", workload.Kind) - r.updateCondition(ctx, agentCard, "Synced", metav1.ConditionFalse, "WorkloadNotReady", - fmt.Sprintf("%s %s is not ready", workload.Kind, workload.Name)) + if condErr := r.updateCondition(ctx, agentCard, "Synced", metav1.ConditionFalse, "WorkloadNotReady", + fmt.Sprintf("%s %s is not ready", workload.Kind, workload.Name)); condErr != nil { + return ctrl.Result{}, condErr + } return ctrl.Result{RequeueAfter: 10 * time.Second}, nil } - // Get protocol from workload labels protocol := getWorkloadProtocol(workload.Labels) if protocol == "" { - agentCardLogger.Info("No protocol label found, skipping sync", "workload", workload.Name) - r.updateCondition(ctx, agentCard, "Synced", metav1.ConditionFalse, "NoProtocol", - "Workload does not have kagenti.io/protocol label") + if condErr := r.updateCondition(ctx, agentCard, "Synced", metav1.ConditionFalse, "NoProtocol", + "Workload does not have kagenti.io/protocol label"); condErr != nil { + return ctrl.Result{}, condErr + } return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil } - // Get the service to determine the endpoint service, err := r.getService(ctx, agentCard.Namespace, workload.ServiceName) if err != nil { agentCardLogger.Error(err, "Failed to get service", "service", workload.ServiceName) - r.updateCondition(ctx, agentCard, "Synced", metav1.ConditionFalse, "ServiceNotFound", err.Error()) + if condErr := r.updateCondition(ctx, agentCard, "Synced", metav1.ConditionFalse, "ServiceNotFound", err.Error()); condErr != nil { + return ctrl.Result{}, condErr + } return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } - // Get service port servicePort := r.getServicePort(service) serviceURL := agentcard.GetServiceURL(workload.ServiceName, agentCard.Namespace, servicePort) - // Fetch the agent card data cardData, err := r.AgentFetcher.Fetch(ctx, protocol, serviceURL) if err != nil { agentCardLogger.Error(err, "Failed to fetch agent card", "workload", workload.Name, "url", serviceURL) - r.updateCondition(ctx, agentCard, "Synced", metav1.ConditionFalse, "FetchFailed", err.Error()) + if condErr := r.updateCondition(ctx, agentCard, "Synced", metav1.ConditionFalse, "FetchFailed", err.Error()); condErr != nil { + return ctrl.Result{}, condErr + } return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil } - // Verify signature before mutating card data (URL override etc.). var verificationResult *signature.VerificationResult if r.RequireSignature { var verifyErr error @@ -226,7 +233,6 @@ func (r *AgentCardReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( agentCardLogger.Error(verifyErr, "Signature verification error", "workload", workload.Name) } - // Emit events for signature verification results if verificationResult != nil { if verificationResult.Verified { if r.Recorder != nil { @@ -249,10 +255,12 @@ func (r *AgentCardReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } } - // Override the URL with the actual in-cluster Service URL. + if r.RequireSignature && verificationResult != nil && verificationResult.Verified { + r.maybeRestartForResign(ctx, agentCard, workload, verificationResult) + } + cardData.URL = serviceURL - // Compute card_id for drift detection (optional) cardId := r.computeCardId(cardData) if cardId != "" && agentCard.Status.CardId != "" && agentCard.Status.CardId != cardId { if r.Recorder != nil { @@ -262,14 +270,12 @@ func (r *AgentCardReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( agentCardLogger.Info("Card content changed", "agentCard", agentCard.Name, "previousCardId", agentCard.Status.CardId, "newCardId", cardId) } - // Build resolved targetRef for status resolvedTargetRef := &agentv1alpha1.TargetRef{ APIVersion: workload.APIVersion, Kind: workload.Kind, Name: workload.Name, } - // Compute binding before the status write so everything is persisted in one API call. var bindingPassed bool var binding *bindingResult var identityMatch *bool @@ -285,7 +291,6 @@ func (r *AgentCardReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( identityMatch = &match } - // Persist all status fields in one write. var vr *signature.VerificationResult if r.RequireSignature { vr = verificationResult @@ -295,19 +300,18 @@ func (r *AgentCardReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, err } - // Propagate the signature-verified label to the workload's pod template. - // If identity binding is configured, both signature and binding must pass. + // Both signature and binding (if configured) must pass for the label. if r.RequireSignature { isVerified := sigVerified if agentCard.Spec.IdentityBinding != nil { isVerified = isVerified && bindingPassed } - if err := r.propagateSignatureLabel(ctx, workload, isVerified); err != nil { + if err := r.propagateSignatureLabel(ctx, agentCard.Name, workload, isVerified); err != nil { agentCardLogger.Error(err, "Failed to propagate signature label to workload", "workload", workload.Name, "verified", isVerified) + return ctrl.Result{}, err } - // Reject if verification failed and not in audit mode if verificationResult != nil && !verificationResult.Verified && !r.SignatureAuditMode { agentCardLogger.Info("Signature verification failed, rejecting agent card", "workload", workload.Name, @@ -316,37 +320,28 @@ func (r *AgentCardReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } } - // Calculate next sync time based on syncPeriod syncPeriod := r.getSyncPeriod(agentCard) - agentCardLogger.Info("Successfully synced agent card", "workload", workload.Name, "kind", workload.Kind, "nextSync", syncPeriod) + agentCardLogger.V(1).Info("Successfully synced agent card", "workload", workload.Name, "kind", workload.Kind, "nextSync", syncPeriod) return ctrl.Result{RequeueAfter: syncPeriod}, nil } -// getWorkload fetches the workload using targetRef. -// The deprecated selector field is no longer supported — use targetRef for explicit workload references. func (r *AgentCardReconciler) getWorkload(ctx context.Context, agentCard *agentv1alpha1.AgentCard) (*WorkloadInfo, error) { - if agentCard.Spec.TargetRef != nil { - return r.getWorkloadByTargetRef(ctx, agentCard.Namespace, agentCard.Spec.TargetRef) + targetRef := agentCard.Spec.TargetRef + if targetRef == nil { + return nil, fmt.Errorf("spec.targetRef is required: specify the workload backing this agent") } - return nil, fmt.Errorf("spec.targetRef is required: specify the workload backing this agent") -} - -// getWorkloadByTargetRef fetches the workload referenced by targetRef using duck typing -func (r *AgentCardReconciler) getWorkloadByTargetRef(ctx context.Context, namespace string, targetRef *agentv1alpha1.TargetRef) (*WorkloadInfo, error) { - // Parse the GroupVersion from APIVersion + namespace := agentCard.Namespace gv, err := schema.ParseGroupVersion(targetRef.APIVersion) if err != nil { return nil, fmt.Errorf("invalid apiVersion %s: %w", targetRef.APIVersion, err) } gvk := gv.WithKind(targetRef.Kind) - // Create an unstructured object to fetch any resource type obj := &unstructured.Unstructured{} obj.SetGroupVersionKind(gvk) - // Fetch the workload key := client.ObjectKey{Namespace: namespace, Name: targetRef.Name} if err := r.Get(ctx, key, obj); err != nil { if apierrors.IsNotFound(err) { @@ -357,14 +352,11 @@ func (r *AgentCardReconciler) getWorkloadByTargetRef(ctx context.Context, namesp } labels := obj.GetLabels() - - // Validate it's a Kagenti agent if !isAgentWorkload(labels) { return nil, fmt.Errorf("%w: %s %s does not have kagenti.io/type=agent label", ErrNotAgentWorkload, targetRef.Kind, targetRef.Name) } - // Determine readiness based on workload type ready := r.isWorkloadReady(obj, targetRef.Kind) return &WorkloadInfo{ @@ -374,11 +366,10 @@ func (r *AgentCardReconciler) getWorkloadByTargetRef(ctx context.Context, namesp Kind: targetRef.Kind, Labels: labels, Ready: ready, - ServiceName: targetRef.Name, // Convention: service has same name as workload + ServiceName: targetRef.Name, }, nil } -// isWorkloadReady determines if a workload is ready to serve traffic using duck typing func (r *AgentCardReconciler) isWorkloadReady(obj *unstructured.Unstructured, kind string) bool { switch kind { case "Deployment": @@ -386,17 +377,14 @@ func (r *AgentCardReconciler) isWorkloadReady(obj *unstructured.Unstructured, ki case "StatefulSet": return isStatefulSetReadyFromUnstructured(obj) default: - // For unknown types, check for common ready conditions return hasReadyCondition(obj) } } -// isAgentWorkload checks if labels indicate this is a Kagenti agent func isAgentWorkload(labels map[string]string) bool { return labels != nil && labels[LabelAgentType] == LabelValueAgent } -// isDeploymentReadyFromUnstructured checks Deployment readiness from unstructured func isDeploymentReadyFromUnstructured(obj *unstructured.Unstructured) bool { conditions, found, err := unstructured.NestedSlice(obj.Object, "status", "conditions") if err != nil || !found { @@ -415,9 +403,8 @@ func isDeploymentReadyFromUnstructured(obj *unstructured.Unstructured) bool { return false } -// isStatefulSetReadyFromUnstructured checks StatefulSet readiness from unstructured. -// A StatefulSet is ready when it has at least one running replica and all replicas are ready. -// Note: A StatefulSet scaled to 0 replicas intentionally returns false (not ready to serve). +// isStatefulSetReadyFromUnstructured returns true when readyReplicas > 0 and all replicas are ready. +// A StatefulSet scaled to 0 intentionally returns false (not ready to serve). func isStatefulSetReadyFromUnstructured(obj *unstructured.Unstructured) bool { readyReplicas, _, err := unstructured.NestedInt64(obj.Object, "status", "readyReplicas") if err != nil { @@ -430,7 +417,6 @@ func isStatefulSetReadyFromUnstructured(obj *unstructured.Unstructured) bool { return readyReplicas > 0 && readyReplicas == replicas } -// hasReadyCondition is a generic check for workloads with standard conditions func hasReadyCondition(obj *unstructured.Unstructured) bool { conditions, found, err := unstructured.NestedSlice(obj.Object, "status", "conditions") if err != nil || !found { @@ -451,27 +437,22 @@ func hasReadyCondition(obj *unstructured.Unstructured) bool { return false } -// getWorkloadProtocol extracts the protocol from workload labels -// Supports both old (kagenti.io/agent-protocol) and new (kagenti.io/protocol) labels +// getWorkloadProtocol returns the protocol label, preferring the new key over the deprecated one. func getWorkloadProtocol(labels map[string]string) string { if labels == nil { return "" } - // Try new label first if protocol := labels[LabelKagentiProtocol]; protocol != "" { return protocol } - // Fall back to old label with deprecation warning if protocol := labels[LabelAgentProtocol]; protocol != "" { - // Use V(1) to reduce log noise - this helper is called frequently - agentCardLogger.V(1).Info("Workload uses deprecated label 'kagenti.io/agent-protocol', please migrate to 'kagenti.io/protocol'", + agentCardLogger.V(1).Info("Deprecated label kagenti.io/agent-protocol in use; migrate to kagenti.io/protocol", "protocol", protocol) return protocol } return "" } -// getService retrieves a Service by name func (r *AgentCardReconciler) getService(ctx context.Context, namespace, name string) (*corev1.Service, error) { service := &corev1.Service{} err := r.Get(ctx, types.NamespacedName{ @@ -486,18 +467,16 @@ func (r *AgentCardReconciler) getService(ctx context.Context, namespace, name st return service, nil } -// getServicePort extracts the service port (defaults to first port or 8000). -// If no ports are defined on the service, a hardcoded fallback of 8000 is used. +// getServicePort returns the first port, defaulting to 8000 (A2A default). func (r *AgentCardReconciler) getServicePort(service *corev1.Service) int32 { if len(service.Spec.Ports) > 0 { return service.Spec.Ports[0].Port } - agentCardLogger.Info("Service has no ports defined, using default port 8000", + agentCardLogger.Info("No ports defined, using default 8000", "service", service.Name, "namespace", service.Namespace) - return 8000 // default fallback — most A2A agents listen on 8000 + return 8000 } -// getSyncPeriod parses the sync period from the spec or returns default func (r *AgentCardReconciler) getSyncPeriod(agentCard *agentv1alpha1.AgentCard) time.Duration { if agentCard.Spec.SyncPeriod == "" { return DefaultSyncPeriod @@ -513,11 +492,9 @@ func (r *AgentCardReconciler) getSyncPeriod(agentCard *agentv1alpha1.AgentCard) return duration } -// updateAgentCardStatus persists all status fields in a single write. -// binding and identityMatch are nil when identity binding is not configured. +// updateAgentCardStatus persists all status fields atomically with retry. func (r *AgentCardReconciler) updateAgentCardStatus(ctx context.Context, agentCard *agentv1alpha1.AgentCard, cardData *agentv1alpha1.AgentCardData, protocol, cardId string, targetRef *agentv1alpha1.TargetRef, verificationResult *signature.VerificationResult, binding *bindingResult, identityMatch *bool) error { return retry.RetryOnConflict(retry.DefaultRetry, func() error { - // Fetch the latest version latest := &agentv1alpha1.AgentCard{} if err := r.Get(ctx, types.NamespacedName{ Name: agentCard.Name, @@ -526,31 +503,28 @@ func (r *AgentCardReconciler) updateAgentCardStatus(ctx context.Context, agentCa return err } - // Update status fields latest.Status.Card = cardData latest.Status.Protocol = protocol latest.Status.TargetRef = targetRef - latest.Status.LastSyncTime = &metav1.Time{Time: time.Now()} - if cardId != "" { + if cardId != "" && cardId != latest.Status.CardId { + latest.Status.LastSyncTime = &metav1.Time{Time: time.Now()} latest.Status.CardId = cardId + } else if latest.Status.LastSyncTime == nil { + latest.Status.LastSyncTime = &metav1.Time{Time: time.Now()} } - // Update signature verification fields if present if verificationResult != nil { latest.Status.ValidSignature = &verificationResult.Verified latest.Status.SignatureVerificationDetails = verificationResult.Details latest.Status.SignatureKeyID = verificationResult.KeyID - // Only trust the SPIFFE ID when the signature is cryptographically valid. if verificationResult.Verified { latest.Status.SignatureSpiffeID = verificationResult.SpiffeID } else { latest.Status.SignatureSpiffeID = "" } - // Add SignatureVerified condition sigCondition := metav1.Condition{ - Type: "SignatureVerified", - LastTransitionTime: metav1.Now(), + Type: "SignatureVerified", } if verificationResult.Verified { sigCondition.Status = metav1.ConditionTrue @@ -569,14 +543,12 @@ func (r *AgentCardReconciler) updateAgentCardStatus(ctx context.Context, agentCa meta.SetStatusCondition(&latest.Status.Conditions, sigCondition) } - // Update Synced condition based on verification result if verificationResult != nil && !verificationResult.Verified && !r.SignatureAuditMode { meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ - Type: "Synced", - Status: metav1.ConditionFalse, - LastTransitionTime: metav1.Now(), - Reason: ReasonSignatureInvalid, - Message: verificationResult.Details, + Type: "Synced", + Status: metav1.ConditionFalse, + Reason: ReasonSignatureInvalid, + Message: verificationResult.Details, }) } else { message := fmt.Sprintf("Successfully fetched agent card for %s", cardData.Name) @@ -584,27 +556,23 @@ func (r *AgentCardReconciler) updateAgentCardStatus(ctx context.Context, agentCa message = fmt.Sprintf("Fetched agent card for %s (signature verification failed but audit mode enabled)", cardData.Name) } meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ - Type: "Synced", - Status: metav1.ConditionTrue, - LastTransitionTime: metav1.Now(), - Reason: "SyncSucceeded", - Message: message, + Type: "Synced", + Status: metav1.ConditionTrue, + Reason: "SyncSucceeded", + Message: message, }) } meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ - Type: "Ready", - Status: metav1.ConditionTrue, - LastTransitionTime: metav1.Now(), - Reason: "ReadyToServe", - Message: "Agent index is ready for queries", + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "ReadyToServe", + Message: "Agent index is ready for queries", }) - // Write binding status if computed. if binding != nil { existingBound := meta.FindStatusCondition(latest.Status.Conditions, "Bound") - // Emit the AllowlistOnly warning once — on the first binding evaluation. if existingBound == nil { agentCardLogger.Info("Identity binding is allowlist-only; SPIFFE trust bundle verification not yet available", "agentCard", latest.Name) @@ -614,7 +582,6 @@ func (r *AgentCardReconciler) updateAgentCardStatus(ctx context.Context, agentCa } } - // Emit binding events only on state transitions to avoid flooding the event stream. newConditionStatus := metav1.ConditionFalse if binding.Bound { newConditionStatus = metav1.ConditionTrue @@ -629,34 +596,50 @@ func (r *AgentCardReconciler) updateAgentCardStatus(ctx context.Context, agentCa } } - now := metav1.Now() + bindingChanged := latest.Status.BindingStatus == nil || + latest.Status.BindingStatus.Bound != binding.Bound || + latest.Status.BindingStatus.Reason != binding.Reason || + latest.Status.BindingStatus.Message != binding.Message + var evalTime *metav1.Time + if latest.Status.BindingStatus != nil { + evalTime = latest.Status.BindingStatus.LastEvaluationTime + } + if bindingChanged || evalTime == nil { + now := metav1.Now() + evalTime = &now + } latest.Status.BindingStatus = &agentv1alpha1.BindingStatus{ Bound: binding.Bound, Reason: binding.Reason, Message: binding.Message, - LastEvaluationTime: &now, + LastEvaluationTime: evalTime, } if binding.SpiffeID != "" { latest.Status.ExpectedSpiffeID = binding.SpiffeID } meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ - Type: "Bound", - Status: newConditionStatus, - LastTransitionTime: now, - Reason: binding.Reason, - Message: binding.Message, + Type: "Bound", + Status: newConditionStatus, + Reason: binding.Reason, + Message: binding.Message, }) } - // Always write signatureIdentityMatch — nil clears stale value when IdentityBinding is removed latest.Status.SignatureIdentityMatch = identityMatch return r.Status().Update(ctx, latest) }) } -// verifySignature verifies the JWS signatures on an agent card per A2A spec section 8.4. +// verifySignature delegates to the Provider and records metrics. func (r *AgentCardReconciler) verifySignature(ctx context.Context, cardData *agentv1alpha1.AgentCardData) (*signature.VerificationResult, error) { + if r.SignatureProvider == nil { + return &signature.VerificationResult{ + Verified: false, + Details: "no signature provider configured", + }, nil + } + startTime := time.Now() defer func() { duration := time.Since(startTime).Seconds() @@ -665,7 +648,6 @@ func (r *AgentCardReconciler) verifySignature(ctx context.Context, cardData *age result, err := r.SignatureProvider.VerifySignature(ctx, cardData, cardData.Signatures) - // Ensure result is never nil if result == nil { result = &signature.VerificationResult{ Verified: false, @@ -673,7 +655,6 @@ func (r *AgentCardReconciler) verifySignature(ctx context.Context, cardData *age } } - // Record metrics signature.RecordVerification(r.SignatureProvider.Name(), result.Verified, r.SignatureAuditMode) if err != nil { signature.RecordError(r.SignatureProvider.Name(), "verification_error") @@ -682,81 +663,125 @@ func (r *AgentCardReconciler) verifySignature(ctx context.Context, cardData *age return result, err } -// propagateSignatureLabel adds or removes the signature-verified label on the -// workload's pod template, enabling NetworkPolicy-based traffic control. -func (r *AgentCardReconciler) propagateSignatureLabel(ctx context.Context, workload *WorkloadInfo, verified bool) error { - if workload == nil { - return nil - } - - key := types.NamespacedName{Name: workload.Name, Namespace: workload.Namespace} +type podTemplateAccessor struct { + obj client.Object + getLabels func(client.Object) map[string]string + setLabels func(client.Object, map[string]string) +} - switch workload.Kind { +func newPodTemplateAccessor(kind string) (*podTemplateAccessor, bool) { + switch kind { case "Deployment": - return r.propagateLabelToWorkload(ctx, key, workload, verified, &appsv1.Deployment{}, - func(obj client.Object) map[string]string { return obj.(*appsv1.Deployment).Spec.Template.Labels }, - func(obj client.Object, labels map[string]string) { - obj.(*appsv1.Deployment).Spec.Template.Labels = labels - }, - ) + return &podTemplateAccessor{ + obj: &appsv1.Deployment{}, + getLabels: func(o client.Object) map[string]string { return o.(*appsv1.Deployment).Spec.Template.Labels }, + setLabels: func(o client.Object, l map[string]string) { o.(*appsv1.Deployment).Spec.Template.Labels = l }, + }, true case "StatefulSet": - return r.propagateLabelToWorkload(ctx, key, workload, verified, &appsv1.StatefulSet{}, - func(obj client.Object) map[string]string { return obj.(*appsv1.StatefulSet).Spec.Template.Labels }, - func(obj client.Object, labels map[string]string) { - obj.(*appsv1.StatefulSet).Spec.Template.Labels = labels - }, - ) + return &podTemplateAccessor{ + obj: &appsv1.StatefulSet{}, + getLabels: func(o client.Object) map[string]string { return o.(*appsv1.StatefulSet).Spec.Template.Labels }, + setLabels: func(o client.Object, l map[string]string) { o.(*appsv1.StatefulSet).Spec.Template.Labels = l }, + }, true default: + return nil, false + } +} + +func (r *AgentCardReconciler) propagateSignatureLabel(ctx context.Context, cardName string, workload *WorkloadInfo, verified bool) error { + if workload == nil { + return nil + } + + acc, ok := newPodTemplateAccessor(workload.Kind) + if !ok { agentCardLogger.V(1).Info("Cannot propagate signature label to unsupported workload kind", "kind", workload.Kind, "workload", workload.Name) return nil } + + key := types.NamespacedName{Name: workload.Name, Namespace: workload.Namespace} + return r.propagateLabelToWorkload(ctx, cardName, key, workload, verified, acc) } -// propagateLabelToWorkload is a generic helper that adds or removes the signature-verified -// label on a workload's pod template. It avoids unnecessary updates (and thus rollouts) -// when the label is already in the desired state. +// propagateLabelToWorkload writes the per-card annotation and AND-aggregates all cards +// to compute the workload-level signature-verified label. func (r *AgentCardReconciler) propagateLabelToWorkload( ctx context.Context, + cardName string, key types.NamespacedName, workload *WorkloadInfo, verified bool, - obj client.Object, - getLabels func(client.Object) map[string]string, - setLabels func(client.Object, map[string]string), + acc *podTemplateAccessor, ) error { + perCardAnno := AnnotationVerifiedStatePrefix + cardName + desiredState := "false" + if verified { + desiredState = "true" + } + return retry.RetryOnConflict(retry.DefaultRetry, func() error { - if err := r.Get(ctx, key, obj); err != nil { + if err := r.Get(ctx, key, acc.obj); err != nil { return err } - labels := getLabels(obj) + + annotations := acc.obj.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + + labels := acc.getLabels(acc.obj) if labels == nil { labels = make(map[string]string) - setLabels(obj, labels) - } - current := labels[LabelSignatureVerified] - // No change needed — avoid unnecessary rollout - if verified && current == "true" { - return nil + acc.setLabels(acc.obj, labels) } - if !verified && current == "" { - return nil + + if annotations[perCardAnno] == desiredState { + aggregated := r.aggregateVerifiedState(annotations) + currentLabel := labels[LabelSignatureVerified] + labelCorrect := (aggregated && currentLabel == "true") || (!aggregated && currentLabel == "") + if labelCorrect { + return nil + } } - if verified { + + annotations[perCardAnno] = desiredState + + delete(annotations, AnnotationLastVerifiedState) + + acc.obj.SetAnnotations(annotations) + + aggregated := r.aggregateVerifiedState(annotations) + if aggregated { labels[LabelSignatureVerified] = "true" } else { delete(labels, LabelSignatureVerified) } + agentCardLogger.Info("Propagating signature-verified label to pod template", "kind", workload.Kind, "workload", workload.Name, - "verified", verified) - return r.Update(ctx, obj) + "card", cardName, + "cardVerified", verified, + "aggregatedVerified", aggregated) + return r.Update(ctx, acc.obj) }) } -// updateCondition updates a specific condition on the AgentCard status. -// Returns an error if the status update fails after retries. +// aggregateVerifiedState returns true only when all per-card annotations are "true". +func (r *AgentCardReconciler) aggregateVerifiedState(annotations map[string]string) bool { + found := false + for k, v := range annotations { + if strings.HasPrefix(k, AnnotationVerifiedStatePrefix) { + found = true + if v != "true" { + return false + } + } + } + return found +} + func (r *AgentCardReconciler) updateCondition(ctx context.Context, agentCard *agentv1alpha1.AgentCard, conditionType string, status metav1.ConditionStatus, reason, message string) error { if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { latest := &agentv1alpha1.AgentCard{} @@ -768,11 +793,10 @@ func (r *AgentCardReconciler) updateCondition(ctx context.Context, agentCard *ag } meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ - Type: conditionType, - Status: status, - LastTransitionTime: metav1.Now(), - Reason: reason, - Message: message, + Type: conditionType, + Status: status, + Reason: reason, + Message: message, }) return r.Status().Update(ctx, latest) @@ -783,14 +807,12 @@ func (r *AgentCardReconciler) updateCondition(ctx context.Context, agentCard *ag return nil } -// handleDeletion handles cleanup when an AgentCard is deleted func (r *AgentCardReconciler) handleDeletion(ctx context.Context, agentCard *agentv1alpha1.AgentCard) (ctrl.Result, error) { if controllerutil.ContainsFinalizer(agentCard, AgentCardFinalizer) { agentCardLogger.Info("Cleaning up AgentCard", "name", agentCard.Name) - // Perform any cleanup here if needed + r.cleanupPerCardAnnotation(ctx, agentCard) - // Remove finalizer if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { latest := &agentv1alpha1.AgentCard{} if err := r.Get(ctx, types.NamespacedName{ @@ -813,52 +835,63 @@ func (r *AgentCardReconciler) handleDeletion(ctx context.Context, agentCard *age return ctrl.Result{}, nil } -// mapWorkloadToAgentCard maps Deployment/StatefulSet events to AgentCard reconcile requests -func (r *AgentCardReconciler) mapWorkloadToAgentCard(apiVersion, kind string) handler.MapFunc { - return func(ctx context.Context, obj client.Object) []reconcile.Request { - if !isAgentWorkload(obj.GetLabels()) { +// cleanupPerCardAnnotation removes this card's annotation from the workload and re-aggregates the label. +func (r *AgentCardReconciler) cleanupPerCardAnnotation(ctx context.Context, agentCard *agentv1alpha1.AgentCard) { + if agentCard.Spec.TargetRef == nil { + return + } + ref := agentCard.Spec.TargetRef + + acc, ok := newPodTemplateAccessor(ref.Kind) + if !ok { + return + } + + key := types.NamespacedName{Name: ref.Name, Namespace: agentCard.Namespace} + perCardAnno := AnnotationVerifiedStatePrefix + agentCard.Name + + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + if err := r.Get(ctx, key, acc.obj); err != nil { + return client.IgnoreNotFound(err) + } + annotations := acc.obj.GetAnnotations() + if annotations == nil { return nil } - - // Use the field indexer to find AgentCards that reference this workload by name, - // scoped to the same namespace. This avoids listing every AgentCard. - agentCardList := &agentv1alpha1.AgentCardList{} - if err := r.List(ctx, agentCardList, - client.InNamespace(obj.GetNamespace()), - client.MatchingFields{TargetRefNameIndex: obj.GetName()}, - ); err != nil { - agentCardLogger.Error(err, "Failed to list AgentCards for mapping") + if _, exists := annotations[perCardAnno]; !exists { return nil } - var requests []reconcile.Request - for _, agentCard := range agentCardList.Items { - // Double-check apiVersion and kind since the index only matches on name. - if r.targetRefMatchesWorkload(&agentCard, obj, apiVersion, kind) { - requests = append(requests, reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: agentCard.Name, - Namespace: agentCard.Namespace, - }, - }) - } + delete(annotations, perCardAnno) + acc.obj.SetAnnotations(annotations) + + labels := acc.getLabels(acc.obj) + if labels == nil { + labels = make(map[string]string) + acc.setLabels(acc.obj, labels) } - return requests + aggregated := r.aggregateVerifiedState(annotations) + if aggregated { + labels[LabelSignatureVerified] = "true" + } else { + delete(labels, LabelSignatureVerified) + } + + agentCardLogger.Info("Cleaned up per-card annotation on workload deletion", + "card", agentCard.Name, "workload", ref.Name, "aggregatedVerified", aggregated) + return r.Update(ctx, acc.obj) + }) + if err != nil { + agentCardLogger.Error(err, "Failed to clean up per-card annotation", + "card", agentCard.Name, "workload", ref.Name) } } -// targetRefMatchesWorkload checks if an AgentCard targetRef matches a workload -func (r *AgentCardReconciler) targetRefMatchesWorkload(agentCard *agentv1alpha1.AgentCard, obj client.Object, apiVersion, kind string) bool { - if agentCard.Spec.TargetRef == nil { - return false - } - return agentCard.Spec.TargetRef.Name == obj.GetName() && - agentCard.Spec.TargetRef.Kind == kind && - agentCard.Spec.TargetRef.APIVersion == apiVersion +func (r *AgentCardReconciler) mapWorkloadToAgentCard(apiVersion, kind string) handler.MapFunc { + return mapWorkloadToAgentCards(r.Client, apiVersion, kind, agentCardLogger) } -// agentLabelPredicate filters for resources with kagenti.io/type=agent func agentLabelPredicate() predicate.Predicate { return predicate.NewPredicateFuncs(func(obj client.Object) bool { labels := obj.GetLabels() @@ -866,86 +899,102 @@ func agentLabelPredicate() predicate.Predicate { }) } -// bindingResult holds the computed identity binding state (pure logic, no API call). +// ignoreOperatorLabelUpdatePredicate suppresses Update events caused by the operator's own +// label/annotation propagation, preventing reconciliation loops. +func ignoreOperatorLabelUpdatePredicate() predicate.Predicate { + return predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + if e.ObjectOld == nil || e.ObjectNew == nil { + return true + } + oldAnnos := e.ObjectOld.GetAnnotations() + newAnnos := e.ObjectNew.GetAnnotations() + + if oldAnnos[AnnotationLastVerifiedState] != newAnnos[AnnotationLastVerifiedState] { + return false + } + + if oldAnnos[AnnotationBundleHash] != newAnnos[AnnotationBundleHash] { + return false + } + + for k, newVal := range newAnnos { + if strings.HasPrefix(k, AnnotationVerifiedStatePrefix) { + if oldAnnos[k] != newVal { + return false + } + } + } + for k := range oldAnnos { + if strings.HasPrefix(k, AnnotationVerifiedStatePrefix) { + if _, exists := newAnnos[k]; !exists { + return false + } + } + } + + return true + }, + } +} + type bindingResult struct { Bound bool Reason string Message string - SpiffeID string // the verified SPIFFE ID used for the evaluation + SpiffeID string } -// computeBinding evaluates identity binding without writing status. -// verifiedSpiffeID is the cryptographically verified SPIFFE ID from the JWS -// protected header; it is empty when the signature is invalid or unsigned. -// Binding fails if verifiedSpiffeID is empty — there is no fallback path. -// -// LIMITATION: The spiffe_id in the JWS header is currently a self-asserted claim. -// Anyone with access to the signing key can embed an arbitrary spiffe_id. -// True workload identity binding requires verifying signatures against a SPIFFE -// trust bundle so the SPIFFE ID is guaranteed by the CA, not self-declared. -// TODO: Replace SecretProvider with SPIFFE trust bundle verification to close -// this gap (see github.com/kagenti/kagenti-operator/pull/176). +// computeBinding evaluates trust-domain identity binding. verifiedSpiffeID is empty when unsigned. func (r *AgentCardReconciler) computeBinding(agentCard *agentv1alpha1.AgentCard, verifiedSpiffeID string) *bindingResult { binding := agentCard.Spec.IdentityBinding if binding == nil { return nil } - // No cryptographically-bound SPIFFE ID available — binding cannot proceed. if verifiedSpiffeID == "" { - reason := ReasonNotBound - message := "No SPIFFE ID in JWS protected header: sign the card with --spiffe-id to embed the workload identity" - agentCardLogger.Info("Identity binding failed: no SPIFFE ID in JWS protected header", - "agentCard", agentCard.Name, - "hint", "Use --spiffe-id when signing to embed the SPIFFE ID in the JWS protected header") - return &bindingResult{Bound: false, Reason: reason, Message: message} - } - - // Defensive: CRD schema enforces minItems=1, but guard against an empty allowlist - // to avoid silently failing all bindings if validation is bypassed. - if len(binding.AllowedSpiffeIDs) == 0 { - agentCardLogger.Error(nil, "BUG: allowedSpiffeIDs is empty — CRD validation may have been bypassed", - "agentCard", agentCard.Name) return &bindingResult{ Bound: false, Reason: ReasonNotBound, - Message: "allowedSpiffeIDs is empty: at least one SPIFFE ID must be specified", + Message: "No SPIFFE ID from x5c certificate chain: ensure the card is signed with a SPIRE-issued SVID", } } - // Check if verified SPIFFE ID is in the allowlist - bound := false - for _, allowedID := range binding.AllowedSpiffeIDs { - if string(allowedID) == verifiedSpiffeID { - bound = true - break + trustDomain := binding.TrustDomain + if trustDomain == "" { + trustDomain = r.SpireTrustDomain + } + if trustDomain == "" { + return &bindingResult{ + Bound: false, + Reason: ReasonNotBound, + Message: "No trust domain configured (set --spire-trust-domain or spec.identityBinding.trustDomain)", } } + prefix := "spiffe://" + trustDomain + "/" + bound := strings.HasPrefix(verifiedSpiffeID, prefix) && len(verifiedSpiffeID) > len(prefix) + if !bound { - agentCardLogger.Info("SPIFFE ID mismatch", + agentCardLogger.Info("Trust domain mismatch", "verifiedSpiffeID", verifiedSpiffeID, - "allowedSpiffeIDs", binding.AllowedSpiffeIDs, - "hint", "Ensure the spiffe_id in the JWS protected header matches an entry in allowedSpiffeIDs") + "expectedTrustDomain", trustDomain) + signature.IncrementTrustDomainMismatch() } - // Determine reason and message var reason, message string if bound { reason = ReasonBound - message = fmt.Sprintf("SPIFFE ID %s (source: jws-protected-header) is in the allowlist", verifiedSpiffeID) + message = fmt.Sprintf("SPIFFE ID %s belongs to trust domain %s", verifiedSpiffeID, trustDomain) } else { reason = ReasonNotBound - message = fmt.Sprintf("SPIFFE ID %s (source: jws-protected-header) is not in the allowlist", verifiedSpiffeID) + message = fmt.Sprintf("SPIFFE ID %s does not belong to trust domain %s", verifiedSpiffeID, trustDomain) } - // Note: binding events are emitted in updateAgentCardStatus only on state transitions - // to avoid flooding the event stream on every reconcile cycle. return &bindingResult{Bound: bound, Reason: reason, Message: message, SpiffeID: verifiedSpiffeID} } -// updateBindingStatus updates binding status when the main status write is unreachable -// (e.g. getWorkload fails before card data is available). +// updateBindingStatus writes binding status when the main status path is unreachable. func (r *AgentCardReconciler) updateBindingStatus(ctx context.Context, agentCard *agentv1alpha1.AgentCard, bound bool, reason, message, expectedSpiffeID string) error { return retry.RetryOnConflict(retry.DefaultRetry, func() error { latest := &agentv1alpha1.AgentCard{} @@ -956,12 +1005,23 @@ func (r *AgentCardReconciler) updateBindingStatus(ctx context.Context, agentCard return err } - now := metav1.Now() + bindingChanged := latest.Status.BindingStatus == nil || + latest.Status.BindingStatus.Bound != bound || + latest.Status.BindingStatus.Reason != reason || + latest.Status.BindingStatus.Message != message + var evalTime *metav1.Time + if latest.Status.BindingStatus != nil { + evalTime = latest.Status.BindingStatus.LastEvaluationTime + } + if bindingChanged || evalTime == nil { + now := metav1.Now() + evalTime = &now + } latest.Status.BindingStatus = &agentv1alpha1.BindingStatus{ Bound: bound, Reason: reason, Message: message, - LastEvaluationTime: &now, + LastEvaluationTime: evalTime, } if expectedSpiffeID != "" { latest.Status.ExpectedSpiffeID = expectedSpiffeID @@ -972,24 +1032,17 @@ func (r *AgentCardReconciler) updateBindingStatus(ctx context.Context, agentCard conditionStatus = metav1.ConditionTrue } meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ - Type: "Bound", - Status: conditionStatus, - LastTransitionTime: now, - Reason: reason, - Message: message, + Type: "Bound", + Status: conditionStatus, + Reason: reason, + Message: message, }) return r.Status().Update(ctx, latest) }) } -// computeCardId computes a SHA256 hash of the card data for drift detection. -// json.Marshal on a Go struct produces deterministic output (field order follows -// the struct definition, not a map). The hash is only compared within this operator. -// -// NOTE: Do NOT use this for JWS signing — use signature.CreateCanonicalCardJSON -// instead, which produces spec-compliant canonical JSON (sorted keys, no whitespace, -// signatures field excluded). +// computeCardId returns a SHA-256 hash of the card data for drift detection. func (r *AgentCardReconciler) computeCardId(cardData *agentv1alpha1.AgentCardData) string { if cardData == nil { return "" @@ -1003,40 +1056,157 @@ func (r *AgentCardReconciler) computeCardId(cardData *agentv1alpha1.AgentCardDat return hex.EncodeToString(hash[:]) } -// SetupWithManager sets up the controller with the Manager. -func (r *AgentCardReconciler) SetupWithManager(mgr ctrl.Manager) error { - // Initialize the fetcher if not set - if r.AgentFetcher == nil { - r.AgentFetcher = agentcard.NewFetcher() +// maybeRestartForResign checks two conditions and triggers a rolling restart if either is true: +// 1. The leaf SVID cert is approaching expiry (within SVIDExpiryGracePeriod). +// 2. The trust bundle hash changed since the workload was last (re)started. +// +// Both feed into the same mechanism: patch the pod template annotation to trigger a rollout. +// The init-container re-runs, fetches a fresh SVID, and re-signs the card. +func (r *AgentCardReconciler) maybeRestartForResign(ctx context.Context, agentCard *agentv1alpha1.AgentCard, workload *WorkloadInfo, vr *signature.VerificationResult) { + if workload == nil || r.SignatureProvider == nil { + return } - // Initialize the signature provider if not set - if r.SignatureProvider == nil { - r.SignatureProvider = signature.NewNoOpProvider() + acc, ok := newPodTemplateAccessor(workload.Kind) + if !ok { + return + } + + key := types.NamespacedName{Name: workload.Name, Namespace: workload.Namespace} + if err := r.Get(ctx, key, acc.obj); err != nil { + return } - // Inject the Kubernetes client into providers that need it - if secretProvider, ok := r.SignatureProvider.(*signature.SecretProvider); ok { - secretProvider.SetClient(mgr.GetClient()) + + annotations := acc.obj.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + + currentBundleHash := r.SignatureProvider.BundleHash() + workloadBundleHash := annotations[AnnotationBundleHash] + + needsRestart := false + reason := "" + + grace := r.SVIDExpiryGracePeriod + if grace == 0 { + grace = DefaultSVIDExpiryGracePeriod + } + + if !vr.LeafNotAfter.IsZero() && time.Until(vr.LeafNotAfter) < grace { + needsRestart = true + reason = fmt.Sprintf("SVID leaf cert expiring at %s", vr.LeafNotAfter.Format(time.RFC3339)) + } + + if workloadBundleHash != "" && currentBundleHash != "" && workloadBundleHash != currentBundleHash { + needsRestart = true + reason = "trust bundle changed (CA rotation)" + } + + if !needsRestart { + if workloadBundleHash == "" && currentBundleHash != "" { + if err := r.patchBundleHashAnnotation(ctx, acc, key, currentBundleHash); err != nil { + agentCardLogger.Error(err, "Failed to set initial bundle hash annotation") + } + } + return + } + + agentCardLogger.Info("Triggering proactive workload restart for re-signing", + "workload", workload.Name, "kind", workload.Kind, "reason", reason) + if r.Recorder != nil { + r.Recorder.Event(agentCard, corev1.EventTypeNormal, "ResignTriggered", reason) + } + + r.triggerRolloutRestart(ctx, acc, key, currentBundleHash) +} + +func (r *AgentCardReconciler) triggerRolloutRestart(ctx context.Context, acc *podTemplateAccessor, key types.NamespacedName, bundleHash string) { + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + if err := r.Get(ctx, key, acc.obj); err != nil { + return err + } + + podAnnotations := getPodTemplateAnnotations(acc) + if podAnnotations == nil { + podAnnotations = make(map[string]string) + } + podAnnotations[AnnotationResignTrigger] = time.Now().Format(time.RFC3339) + setPodTemplateAnnotations(acc, podAnnotations) + + objAnnotations := acc.obj.GetAnnotations() + if objAnnotations == nil { + objAnnotations = make(map[string]string) + } + objAnnotations[AnnotationBundleHash] = bundleHash + acc.obj.SetAnnotations(objAnnotations) + + return r.Update(ctx, acc.obj) + }) + if err != nil { + agentCardLogger.Error(err, "Failed to trigger rollout restart", "workload", key.Name) + } +} + +func (r *AgentCardReconciler) patchBundleHashAnnotation(ctx context.Context, acc *podTemplateAccessor, key types.NamespacedName, bundleHash string) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + if err := r.Get(ctx, key, acc.obj); err != nil { + return err + } + annotations := acc.obj.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + if annotations[AnnotationBundleHash] == bundleHash { + return nil + } + annotations[AnnotationBundleHash] = bundleHash + acc.obj.SetAnnotations(annotations) + return r.Update(ctx, acc.obj) + }) +} + +func getPodTemplateAnnotations(acc *podTemplateAccessor) map[string]string { + switch o := acc.obj.(type) { + case *appsv1.Deployment: + return o.Spec.Template.Annotations + case *appsv1.StatefulSet: + return o.Spec.Template.Annotations + } + return nil +} + +func setPodTemplateAnnotations(acc *podTemplateAccessor, annotations map[string]string) { + switch o := acc.obj.(type) { + case *appsv1.Deployment: + o.Spec.Template.Annotations = annotations + case *appsv1.StatefulSet: + o.Spec.Template.Annotations = annotations + } +} + +func (r *AgentCardReconciler) SetupWithManager(mgr ctrl.Manager) error { + if r.AgentFetcher == nil { + r.AgentFetcher = agentcard.NewFetcher() } - // Register the shared field indexer (safe to call from multiple controllers). if err := RegisterAgentCardTargetRefIndex(mgr); err != nil { return err } + workloadPredicates := predicate.And(agentLabelPredicate(), ignoreOperatorLabelUpdatePredicate()) + controllerBuilder := ctrl.NewControllerManagedBy(mgr). For(&agentv1alpha1.AgentCard{}). - // Watch Deployments with agent labels Watches( &appsv1.Deployment{}, handler.EnqueueRequestsFromMapFunc(r.mapWorkloadToAgentCard("apps/v1", "Deployment")), - builder.WithPredicates(agentLabelPredicate()), + builder.WithPredicates(workloadPredicates), ). - // Watch StatefulSets with agent labels Watches( &appsv1.StatefulSet{}, handler.EnqueueRequestsFromMapFunc(r.mapWorkloadToAgentCard("apps/v1", "StatefulSet")), - builder.WithPredicates(agentLabelPredicate()), + builder.WithPredicates(workloadPredicates), ) return controllerBuilder. diff --git a/kagenti-operator/internal/controller/agentcard_controller_test.go b/kagenti-operator/internal/controller/agentcard_controller_test.go index 57ac62a9..b9deed53 100644 --- a/kagenti-operator/internal/controller/agentcard_controller_test.go +++ b/kagenti-operator/internal/controller/agentcard_controller_test.go @@ -26,6 +26,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/reconcile" agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" @@ -238,7 +239,7 @@ var _ = Describe("AgentCard Controller", func() { }) }) -var _ = Describe("AgentCard Controller - getWorkloadByTargetRef", func() { +var _ = Describe("AgentCard Controller - getWorkload", func() { const namespace = "default" var ( @@ -293,13 +294,11 @@ var _ = Describe("AgentCard Controller - getWorkloadByTargetRef", func() { } Expect(k8sClient.Create(ctx, deployment)).To(Succeed()) - By("calling getWorkloadByTargetRef") - targetRef := &agentv1alpha1.TargetRef{ - APIVersion: "apps/v1", - Kind: "Deployment", - Name: deploymentName, - } - workload, err := reconciler.getWorkloadByTargetRef(ctx, namespace, targetRef) + By("calling getWorkload") + workload, err := reconciler.getWorkload(ctx, &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{TargetRef: &agentv1alpha1.TargetRef{APIVersion: "apps/v1", Kind: "Deployment", Name: deploymentName}}, + }) By("verifying the Deployment was found") Expect(err).NotTo(HaveOccurred()) @@ -354,13 +353,11 @@ var _ = Describe("AgentCard Controller - getWorkloadByTargetRef", func() { return k8sClient.Status().Update(ctx, deployment) }).Should(Succeed()) - By("calling getWorkloadByTargetRef") - targetRef := &agentv1alpha1.TargetRef{ - APIVersion: "apps/v1", - Kind: "Deployment", - Name: deploymentName, - } - workload, err := reconciler.getWorkloadByTargetRef(ctx, namespace, targetRef) + By("calling getWorkload") + workload, err := reconciler.getWorkload(ctx, &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{TargetRef: &agentv1alpha1.TargetRef{APIVersion: "apps/v1", Kind: "Deployment", Name: deploymentName}}, + }) By("verifying readiness is detected") Expect(err).NotTo(HaveOccurred()) @@ -408,13 +405,11 @@ var _ = Describe("AgentCard Controller - getWorkloadByTargetRef", func() { } Expect(k8sClient.Create(ctx, statefulSet)).To(Succeed()) - By("calling getWorkloadByTargetRef") - targetRef := &agentv1alpha1.TargetRef{ - APIVersion: "apps/v1", - Kind: "StatefulSet", - Name: statefulSetName, - } - workload, err := reconciler.getWorkloadByTargetRef(ctx, namespace, targetRef) + By("calling getWorkload") + workload, err := reconciler.getWorkload(ctx, &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{TargetRef: &agentv1alpha1.TargetRef{APIVersion: "apps/v1", Kind: "StatefulSet", Name: statefulSetName}}, + }) By("verifying the StatefulSet was found") Expect(err).NotTo(HaveOccurred()) @@ -464,13 +459,11 @@ var _ = Describe("AgentCard Controller - getWorkloadByTargetRef", func() { return k8sClient.Status().Update(ctx, statefulSet) }).Should(Succeed()) - By("calling getWorkloadByTargetRef") - targetRef := &agentv1alpha1.TargetRef{ - APIVersion: "apps/v1", - Kind: "StatefulSet", - Name: statefulSetName, - } - workload, err := reconciler.getWorkloadByTargetRef(ctx, namespace, targetRef) + By("calling getWorkload") + workload, err := reconciler.getWorkload(ctx, &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{TargetRef: &agentv1alpha1.TargetRef{APIVersion: "apps/v1", Kind: "StatefulSet", Name: statefulSetName}}, + }) By("verifying readiness is detected") Expect(err).NotTo(HaveOccurred()) @@ -480,13 +473,10 @@ var _ = Describe("AgentCard Controller - getWorkloadByTargetRef", func() { Context("When targetRef references non-existent workload", func() { It("should return ErrWorkloadNotFound for non-existent Deployment", func() { - targetRef := &agentv1alpha1.TargetRef{ - APIVersion: "apps/v1", - Kind: "Deployment", - Name: "nonexistent-deployment", - } - - workload, err := reconciler.getWorkloadByTargetRef(ctx, namespace, targetRef) + workload, err := reconciler.getWorkload(ctx, &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{TargetRef: &agentv1alpha1.TargetRef{APIVersion: "apps/v1", Kind: "Deployment", Name: "nonexistent-deployment"}}, + }) Expect(err).To(HaveOccurred()) Expect(errors.Is(err, ErrWorkloadNotFound)).To(BeTrue()) @@ -494,13 +484,10 @@ var _ = Describe("AgentCard Controller - getWorkloadByTargetRef", func() { }) It("should return ErrWorkloadNotFound for non-existent StatefulSet", func() { - targetRef := &agentv1alpha1.TargetRef{ - APIVersion: "apps/v1", - Kind: "StatefulSet", - Name: "nonexistent-statefulset", - } - - workload, err := reconciler.getWorkloadByTargetRef(ctx, namespace, targetRef) + workload, err := reconciler.getWorkload(ctx, &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{TargetRef: &agentv1alpha1.TargetRef{APIVersion: "apps/v1", Kind: "StatefulSet", Name: "nonexistent-statefulset"}}, + }) Expect(err).To(HaveOccurred()) Expect(errors.Is(err, ErrWorkloadNotFound)).To(BeTrue()) @@ -547,13 +534,11 @@ var _ = Describe("AgentCard Controller - getWorkloadByTargetRef", func() { } Expect(k8sClient.Create(ctx, deployment)).To(Succeed()) - By("calling getWorkloadByTargetRef") - targetRef := &agentv1alpha1.TargetRef{ - APIVersion: "apps/v1", - Kind: "Deployment", - Name: deploymentName, - } - workload, err := reconciler.getWorkloadByTargetRef(ctx, namespace, targetRef) + By("calling getWorkload") + workload, err := reconciler.getWorkload(ctx, &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{TargetRef: &agentv1alpha1.TargetRef{APIVersion: "apps/v1", Kind: "Deployment", Name: deploymentName}}, + }) By("verifying ErrNotAgentWorkload is returned") Expect(err).To(HaveOccurred()) @@ -697,18 +682,9 @@ var _ = Describe("getWorkloadProtocol", func() { Expect(protocol).To(BeEmpty()) }) - It("should return empty string when labels map is nil", func() { - protocol := getWorkloadProtocol(nil) - - Expect(protocol).To(BeEmpty()) - }) - - It("should return empty string when labels map is empty", func() { - labels := map[string]string{} - - protocol := getWorkloadProtocol(labels) - - Expect(protocol).To(BeEmpty()) + It("should return empty string when labels map is nil or empty", func() { + Expect(getWorkloadProtocol(nil)).To(BeEmpty()) + Expect(getWorkloadProtocol(map[string]string{})).To(BeEmpty()) }) It("should use new label even when legacy label has different value", func() { @@ -764,6 +740,184 @@ var _ = Describe("getServicePort", func() { }) }) +var _ = Describe("ignoreOperatorLabelUpdatePredicate", func() { + pred := ignoreOperatorLabelUpdatePredicate() + + It("should allow updates where no operator annotations changed", func() { + oldDeploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deploy", + Namespace: "default", + Annotations: map[string]string{ + AnnotationVerifiedStatePrefix + "card-a": "true", + }, + }, + } + newDeploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deploy", + Namespace: "default", + Annotations: map[string]string{ + AnnotationVerifiedStatePrefix + "card-a": "true", + }, + }, + } + result := pred.Update(event.UpdateEvent{ObjectOld: oldDeploy, ObjectNew: newDeploy}) + Expect(result).To(BeTrue(), "should allow event when no operator annotations changed") + }) + + It("should suppress updates where a per-card annotation was added", func() { + oldDeploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deploy", + Namespace: "default", + }, + } + newDeploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deploy", + Namespace: "default", + Annotations: map[string]string{ + AnnotationVerifiedStatePrefix + "card-a": "true", + }, + }, + } + result := pred.Update(event.UpdateEvent{ObjectOld: oldDeploy, ObjectNew: newDeploy}) + Expect(result).To(BeFalse(), "should suppress event when per-card annotation added") + }) + + It("should suppress updates where a per-card annotation value changed", func() { + oldDeploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deploy", + Namespace: "default", + Annotations: map[string]string{ + AnnotationVerifiedStatePrefix + "card-a": "true", + }, + }, + } + newDeploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deploy", + Namespace: "default", + Annotations: map[string]string{ + AnnotationVerifiedStatePrefix + "card-a": "false", + }, + }, + } + result := pred.Update(event.UpdateEvent{ObjectOld: oldDeploy, ObjectNew: newDeploy}) + Expect(result).To(BeFalse(), "should suppress event when per-card annotation toggled") + }) + + It("should suppress updates where a per-card annotation was removed", func() { + oldDeploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deploy", + Namespace: "default", + Annotations: map[string]string{ + AnnotationVerifiedStatePrefix + "card-a": "true", + }, + }, + } + newDeploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deploy", + Namespace: "default", + }, + } + result := pred.Update(event.UpdateEvent{ObjectOld: oldDeploy, ObjectNew: newDeploy}) + Expect(result).To(BeFalse(), "should suppress event when per-card annotation removed") + }) + + It("should suppress updates where the legacy annotation changed", func() { + oldDeploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deploy", + Namespace: "default", + }, + } + newDeploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deploy", + Namespace: "default", + Annotations: map[string]string{AnnotationLastVerifiedState: "true"}, + }, + } + result := pred.Update(event.UpdateEvent{ObjectOld: oldDeploy, ObjectNew: newDeploy}) + Expect(result).To(BeFalse(), "should suppress event when legacy annotation changed") + }) + + It("should allow Create events", func() { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-deploy", Namespace: "default"}, + } + result := pred.Create(event.CreateEvent{Object: deploy}) + Expect(result).To(BeTrue(), "Create events should always pass through") + }) + + It("should allow Delete events", func() { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-deploy", Namespace: "default"}, + } + result := pred.Delete(event.DeleteEvent{Object: deploy}) + Expect(result).To(BeTrue(), "Delete events should always pass through") + }) +}) + +var _ = Describe("aggregateVerifiedState", func() { + reconciler := &AgentCardReconciler{} + + It("should return true when all per-card annotations are true", func() { + annotations := map[string]string{ + AnnotationVerifiedStatePrefix + "card-a": "true", + AnnotationVerifiedStatePrefix + "card-b": "true", + "unrelated-annotation": "whatever", + } + Expect(reconciler.aggregateVerifiedState(annotations)).To(BeTrue()) + }) + + It("should return false when any per-card annotation is false", func() { + annotations := map[string]string{ + AnnotationVerifiedStatePrefix + "card-a": "true", + AnnotationVerifiedStatePrefix + "card-b": "false", + } + Expect(reconciler.aggregateVerifiedState(annotations)).To(BeFalse()) + }) + + It("should return false when there are no per-card annotations", func() { + annotations := map[string]string{ + "unrelated": "value", + } + Expect(reconciler.aggregateVerifiedState(annotations)).To(BeFalse()) + }) + + It("should return false for nil annotations", func() { + Expect(reconciler.aggregateVerifiedState(nil)).To(BeFalse()) + }) + + It("should return true for a single card with true", func() { + annotations := map[string]string{ + AnnotationVerifiedStatePrefix + "only-card": "true", + } + Expect(reconciler.aggregateVerifiedState(annotations)).To(BeTrue()) + }) + + It("should return false for a single card with false", func() { + annotations := map[string]string{ + AnnotationVerifiedStatePrefix + "only-card": "false", + } + Expect(reconciler.aggregateVerifiedState(annotations)).To(BeFalse()) + }) + + It("should ignore the legacy annotation", func() { + annotations := map[string]string{ + AnnotationLastVerifiedState: "true", + AnnotationVerifiedStatePrefix + "only-card": "false", + } + Expect(reconciler.aggregateVerifiedState(annotations)).To(BeFalse()) + }) +}) + // Helper function to find a condition by type func findCondition(conditions []metav1.Condition, conditionType string) *metav1.Condition { for i := range conditions { diff --git a/kagenti-operator/internal/controller/agentcard_networkpolicy_controller.go b/kagenti-operator/internal/controller/agentcard_networkpolicy_controller.go index 3e792ea5..72df0408 100644 --- a/kagenti-operator/internal/controller/agentcard_networkpolicy_controller.go +++ b/kagenti-operator/internal/controller/agentcard_networkpolicy_controller.go @@ -29,27 +29,21 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/client-go/util/retry" + "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/reconcile" agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" ) -const ( - // NetworkPolicyFinalizer is the finalizer for cleaning up network policies - NetworkPolicyFinalizer = "agentcard.kagenti.dev/network-policy" -) +const NetworkPolicyFinalizer = "agentcard.kagenti.dev/network-policy" -var ( - networkPolicyLogger = ctrl.Log.WithName("controller").WithName("AgentCardNetworkPolicy") -) +var networkPolicyLogger = ctrl.Log.WithName("controller").WithName("AgentCardNetworkPolicy") -// AgentCardNetworkPolicyReconciler manages NetworkPolicies based on AgentCard -// signature verification status. +// AgentCardNetworkPolicyReconciler manages NetworkPolicies based on AgentCard signature status. type AgentCardNetworkPolicyReconciler struct { client.Client Scheme *runtime.Scheme @@ -60,9 +54,8 @@ type AgentCardNetworkPolicyReconciler struct { // +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;update;patch func (r *AgentCardNetworkPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - networkPolicyLogger.Info("Reconciling AgentCard NetworkPolicy", "namespacedName", req.NamespacedName) + networkPolicyLogger.V(1).Info("Reconciling AgentCard NetworkPolicy", "namespacedName", req.NamespacedName) - // Skip if network policy enforcement is disabled if !r.EnforceNetworkPolicies { return ctrl.Result{}, nil } @@ -76,29 +69,25 @@ func (r *AgentCardNetworkPolicyReconciler) Reconcile(ctx context.Context, req ct return ctrl.Result{}, err } - // Handle deletion if !agentCard.ObjectMeta.DeletionTimestamp.IsZero() { return r.handleDeletion(ctx, agentCard) } - // Add finalizer if !controllerutil.ContainsFinalizer(agentCard, NetworkPolicyFinalizer) { controllerutil.AddFinalizer(agentCard, NetworkPolicyFinalizer) if err := r.Update(ctx, agentCard); err != nil { - networkPolicyLogger.Error(err, "Unable to add finalizer to AgentCard") + networkPolicyLogger.Error(err, "Failed to add finalizer to AgentCard") return ctrl.Result{}, err } return ctrl.Result{}, nil } - // Resolve the workload name and pod selector labels for the NetworkPolicy workloadName, podSelectorLabels, err := r.resolveWorkload(ctx, agentCard) if err != nil { networkPolicyLogger.Info("No workload resolved for AgentCard", "agentCard", agentCard.Name, "error", err) return ctrl.Result{}, nil } - // Manage NetworkPolicy based on verification status if err := r.manageNetworkPolicy(ctx, agentCard, workloadName, podSelectorLabels); err != nil { networkPolicyLogger.Error(err, "Failed to manage NetworkPolicy") return ctrl.Result{}, err @@ -107,22 +96,18 @@ func (r *AgentCardNetworkPolicyReconciler) Reconcile(ctx context.Context, req ct return ctrl.Result{}, nil } -// resolveWorkload resolves the workload name and pod selector labels from the AgentCard. -// Requires spec.targetRef to identify the backing workload. func (r *AgentCardNetworkPolicyReconciler) resolveWorkload(ctx context.Context, agentCard *agentv1alpha1.AgentCard) (string, map[string]string, error) { - if agentCard.Spec.TargetRef != nil { - ref := agentCard.Spec.TargetRef - podLabels, err := r.getPodTemplateLabels(ctx, agentCard.Namespace, ref) - if err != nil { - return "", nil, err - } - return ref.Name, podLabels, nil + ref := agentCard.Spec.TargetRef + if ref == nil { + return "", nil, fmt.Errorf("spec.targetRef is required: specify the workload backing this agent") } - - return "", nil, fmt.Errorf("spec.targetRef is required: specify the workload backing this agent") + podLabels, err := r.getPodTemplateLabels(ctx, agentCard.Namespace, ref) + if err != nil { + return "", nil, err + } + return ref.Name, podLabels, nil } -// getPodTemplateLabels extracts the pod template labels from a workload using targetRef func (r *AgentCardNetworkPolicyReconciler) getPodTemplateLabels(ctx context.Context, namespace string, ref *agentv1alpha1.TargetRef) (map[string]string, error) { key := types.NamespacedName{Name: ref.Name, Namespace: namespace} @@ -142,7 +127,6 @@ func (r *AgentCardNetworkPolicyReconciler) getPodTemplateLabels(ctx context.Cont return statefulset.Spec.Template.Labels, nil default: - // For unknown workload types, use the agent card name as a selector return map[string]string{ LabelAgentType: LabelValueAgent, "app": ref.Name, @@ -150,20 +134,13 @@ func (r *AgentCardNetworkPolicyReconciler) getPodTemplateLabels(ctx context.Cont } } -// manageNetworkPolicy creates or updates a NetworkPolicy based on verification status. -// When identity binding is configured, both signature and binding must pass. func (r *AgentCardNetworkPolicyReconciler) manageNetworkPolicy(ctx context.Context, agentCard *agentv1alpha1.AgentCard, workloadName string, podSelectorLabels map[string]string) error { policyName := fmt.Sprintf("%s-signature-policy", workloadName) - // Determine if the agent should get a permissive policy. - // If identity binding is configured, use SignatureIdentityMatch (both sig + binding). - // Otherwise, use ValidSignature alone. isVerified := false if agentCard.Spec.IdentityBinding != nil { - // Both signature and binding must pass isVerified = agentCard.Status.SignatureIdentityMatch != nil && *agentCard.Status.SignatureIdentityMatch } else { - // Signature only isVerified = agentCard.Status.ValidSignature != nil && *agentCard.Status.ValidSignature } @@ -173,8 +150,6 @@ func (r *AgentCardNetworkPolicyReconciler) manageNetworkPolicy(ctx context.Conte return r.createRestrictivePolicy(ctx, policyName, agentCard, podSelectorLabels) } -// upsertNetworkPolicy creates or updates a NetworkPolicy with the given spec. -// Shared by createPermissivePolicy and createRestrictivePolicy to avoid duplication. func (r *AgentCardNetworkPolicyReconciler) upsertNetworkPolicy(ctx context.Context, policyName string, agentCard *agentv1alpha1.AgentCard, spec netv1.NetworkPolicySpec) error { policy := &netv1.NetworkPolicy{ ObjectMeta: metav1.ObjectMeta{ @@ -205,29 +180,25 @@ func (r *AgentCardNetworkPolicyReconciler) upsertNetworkPolicy(ctx context.Conte } existingPolicy.Spec = spec - // Ensure owner references are up-to-date in case the policy was created - // by a prior version of the operator without owner references. existingPolicy.OwnerReferences = policy.OwnerReferences networkPolicyLogger.Info("Updating NetworkPolicy", "agentCard", agentCard.Name, "policy", policyName) return r.Update(ctx, existingPolicy) } -// dnsEgressPorts returns the standard DNS egress ports (UDP+TCP 53) func dnsEgressPorts() []netv1.NetworkPolicyPort { return []netv1.NetworkPolicyPort{ { - Protocol: func() *corev1.Protocol { p := corev1.ProtocolUDP; return &p }(), + Protocol: ptr.To(corev1.ProtocolUDP), Port: &intstr.IntOrString{Type: intstr.Int, IntVal: 53}, }, { - Protocol: func() *corev1.Protocol { p := corev1.ProtocolTCP; return &p }(), + Protocol: ptr.To(corev1.ProtocolTCP), Port: &intstr.IntOrString{Type: intstr.Int, IntVal: 53}, }, } } -// createPermissivePolicy creates a NetworkPolicy that allows verified agents to communicate func (r *AgentCardNetworkPolicyReconciler) createPermissivePolicy(ctx context.Context, policyName string, agentCard *agentv1alpha1.AgentCard, podSelectorLabels map[string]string) error { spec := netv1.NetworkPolicySpec{ PodSelector: metav1.LabelSelector{MatchLabels: podSelectorLabels}, @@ -285,7 +256,6 @@ func (r *AgentCardNetworkPolicyReconciler) createPermissivePolicy(ctx context.Co return r.upsertNetworkPolicy(ctx, policyName, agentCard, spec) } -// createRestrictivePolicy creates a NetworkPolicy that blocks unverified agents func (r *AgentCardNetworkPolicyReconciler) createRestrictivePolicy(ctx context.Context, policyName string, agentCard *agentv1alpha1.AgentCard, podSelectorLabels map[string]string) error { spec := netv1.NetworkPolicySpec{ PodSelector: metav1.LabelSelector{MatchLabels: podSelectorLabels}, @@ -317,14 +287,10 @@ func (r *AgentCardNetworkPolicyReconciler) createRestrictivePolicy(ctx context.C return r.upsertNetworkPolicy(ctx, policyName, agentCard, spec) } -// handleDeletion handles cleanup when an AgentCard is deleted func (r *AgentCardNetworkPolicyReconciler) handleDeletion(ctx context.Context, agentCard *agentv1alpha1.AgentCard) (ctrl.Result, error) { if controllerutil.ContainsFinalizer(agentCard, NetworkPolicyFinalizer) { networkPolicyLogger.Info("Cleaning up NetworkPolicy for AgentCard", "name", agentCard.Name) - // Determine the policy name: prefer spec.targetRef (source of truth during - // creation) over status.targetRef to avoid orphaned policies if spec.targetRef - // was updated between creation and deletion. workloadName := agentCard.Name if agentCard.Spec.TargetRef != nil { workloadName = agentCard.Spec.TargetRef.Name @@ -332,8 +298,6 @@ func (r *AgentCardNetworkPolicyReconciler) handleDeletion(ctx context.Context, a workloadName = agentCard.Status.TargetRef.Name } - // Warn if spec and status targetRef diverge — the policy for the old - // workload may become orphaned until the owner reference triggers GC. if agentCard.Spec.TargetRef != nil && agentCard.Status.TargetRef != nil && agentCard.Spec.TargetRef.Name != agentCard.Status.TargetRef.Name { networkPolicyLogger.Info("WARNING: spec.targetRef.name differs from status.targetRef.name; "+ @@ -343,7 +307,6 @@ func (r *AgentCardNetworkPolicyReconciler) handleDeletion(ctx context.Context, a } policyName := fmt.Sprintf("%s-signature-policy", workloadName) - // Delete the NetworkPolicy policy := &netv1.NetworkPolicy{} err := r.Get(ctx, types.NamespacedName{Name: policyName, Namespace: agentCard.Namespace}, policy) if err != nil && !apierrors.IsNotFound(err) { @@ -351,14 +314,13 @@ func (r *AgentCardNetworkPolicyReconciler) handleDeletion(ctx context.Context, a return ctrl.Result{}, err } if err == nil { - if err := r.Delete(ctx, policy); err != nil { + if err := r.Delete(ctx, policy); err != nil && !apierrors.IsNotFound(err) { networkPolicyLogger.Error(err, "Failed to delete NetworkPolicy") return ctrl.Result{}, err } networkPolicyLogger.Info("Deleted NetworkPolicy", "policy", policyName) } - // Remove finalizer if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { latest := &agentv1alpha1.AgentCard{} if err := r.Get(ctx, types.NamespacedName{ @@ -381,45 +343,11 @@ func (r *AgentCardNetworkPolicyReconciler) handleDeletion(ctx context.Context, a return ctrl.Result{}, nil } -// mapWorkloadToAgentCard maps Deployment/StatefulSet events to AgentCard reconcile requests. -// Uses a field indexer to avoid listing every AgentCard in the namespace. func (r *AgentCardNetworkPolicyReconciler) mapWorkloadToAgentCard(apiVersion, kind string) handler.MapFunc { - return func(ctx context.Context, obj client.Object) []reconcile.Request { - if !isAgentWorkload(obj.GetLabels()) { - return nil - } - - agentCardList := &agentv1alpha1.AgentCardList{} - if err := r.List(ctx, agentCardList, - client.InNamespace(obj.GetNamespace()), - client.MatchingFields{TargetRefNameIndex: obj.GetName()}, - ); err != nil { - networkPolicyLogger.Error(err, "Failed to list AgentCards for mapping") - return nil - } - - var requests []reconcile.Request - for _, agentCard := range agentCardList.Items { - // Double-check apiVersion and kind since the index only matches on name. - if agentCard.Spec.TargetRef != nil && - agentCard.Spec.TargetRef.Kind == kind && - agentCard.Spec.TargetRef.APIVersion == apiVersion { - requests = append(requests, reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: agentCard.Name, - Namespace: agentCard.Namespace, - }, - }) - } - } - - return requests - } + return mapWorkloadToAgentCards(r.Client, apiVersion, kind, networkPolicyLogger) } -// SetupWithManager sets up the controller with the Manager. func (r *AgentCardNetworkPolicyReconciler) SetupWithManager(mgr ctrl.Manager) error { - // Register the shared field indexer (safe to call from multiple controllers). if err := RegisterAgentCardTargetRefIndex(mgr); err != nil { return err } @@ -427,13 +355,11 @@ func (r *AgentCardNetworkPolicyReconciler) SetupWithManager(mgr ctrl.Manager) er controllerBuilder := ctrl.NewControllerManagedBy(mgr). For(&agentv1alpha1.AgentCard{}). Owns(&netv1.NetworkPolicy{}). - // Watch Deployments with agent labels Watches( &appsv1.Deployment{}, handler.EnqueueRequestsFromMapFunc(r.mapWorkloadToAgentCard("apps/v1", "Deployment")), builder.WithPredicates(agentLabelPredicate()), ). - // Watch StatefulSets with agent labels Watches( &appsv1.StatefulSet{}, handler.EnqueueRequestsFromMapFunc(r.mapWorkloadToAgentCard("apps/v1", "StatefulSet")), diff --git a/kagenti-operator/internal/controller/agentcardsync_controller.go b/kagenti-operator/internal/controller/agentcardsync_controller.go index 0e542622..418cc057 100644 --- a/kagenti-operator/internal/controller/agentcardsync_controller.go +++ b/kagenti-operator/internal/controller/agentcardsync_controller.go @@ -35,36 +35,31 @@ import ( agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" ) -var ( - syncLogger = ctrl.Log.WithName("controller").WithName("AgentCardSync") +var syncLogger = ctrl.Log.WithName("controller").WithName("AgentCardSync") + +const ( + // DefaultAutoSyncGracePeriod prevents duplicate cards when a Deployment and AgentCard + // are applied together (the Deployment event may fire before the manual card is cached). + DefaultAutoSyncGracePeriod = 5 * time.Second + + // LabelManagedBy identifies auto-created AgentCards so the sync controller + // can distinguish its own cards from manually-created ones. + LabelManagedBy = "app.kubernetes.io/managed-by" + LabelManagedByValue = "kagenti-operator" ) -// DefaultAutoSyncGracePeriod is the delay before auto-creating an AgentCard for a -// newly created workload. This prevents duplicate cards when a Deployment and an -// AgentCard are applied together (e.g. in the same kubectl apply) — the Deployment -// event can trigger auto-sync before the manually-created card appears in the cache. -// -// TODO: Remove when Deployment/StatefulSet auto-sync is dropped in favour of -// explicit targetRef-based AgentCard creation. -const DefaultAutoSyncGracePeriod = 5 * time.Second - -// AgentCardSyncReconciler automatically creates AgentCard resources for agent workloads -// (Deployments and StatefulSets) +// AgentCardSyncReconciler auto-creates AgentCards for labelled agent workloads. type AgentCardSyncReconciler struct { client.Client - Scheme *runtime.Scheme - // AutoSyncGracePeriod is the delay before auto-creating an AgentCard for newly - // created workloads. Set to 0 in tests to disable. Defaults to DefaultAutoSyncGracePeriod. - AutoSyncGracePeriod time.Duration + Scheme *runtime.Scheme + AutoSyncGracePeriod time.Duration // 0 = use default; negative = disabled (tests) } -// getAutoSyncGracePeriod returns the configured grace period, defaulting to DefaultAutoSyncGracePeriod. func (r *AgentCardSyncReconciler) getAutoSyncGracePeriod() time.Duration { if r.AutoSyncGracePeriod > 0 { return r.AutoSyncGracePeriod } if r.AutoSyncGracePeriod < 0 { - // Explicitly set to negative = disabled (e.g., tests) return 0 } return DefaultAutoSyncGracePeriod @@ -74,20 +69,17 @@ func (r *AgentCardSyncReconciler) getAutoSyncGracePeriod() time.Duration { // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch // +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch -// ReconcileDeployment handles Deployment events to create/update AgentCards func (r *AgentCardSyncReconciler) ReconcileDeployment(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { syncLogger.V(1).Info("Reconciling Deployment for auto-sync", "namespacedName", req.NamespacedName) deployment := &appsv1.Deployment{} if err := r.Get(ctx, req.NamespacedName, deployment); err != nil { if errors.IsNotFound(err) { - // Deployment deleted - AgentCard cleanup handled by owner references return ctrl.Result{}, nil } return ctrl.Result{}, err } - // Check if this is an agent deployment if !r.shouldSyncWorkload(deployment.Labels) { return ctrl.Result{}, nil } @@ -97,7 +89,6 @@ func (r *AgentCardSyncReconciler) ReconcileDeployment(ctx context.Context, req c return r.ensureAgentCard(ctx, deployment, gvk) } -// ReconcileStatefulSet handles StatefulSet events to create/update AgentCards func (r *AgentCardSyncReconciler) ReconcileStatefulSet(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { syncLogger.V(1).Info("Reconciling StatefulSet for auto-sync", "namespacedName", req.NamespacedName) @@ -117,42 +108,21 @@ func (r *AgentCardSyncReconciler) ReconcileStatefulSet(ctx context.Context, req return r.ensureAgentCard(ctx, statefulset, gvk) } -// shouldSyncWorkload checks if a workload should have an AgentCard created func (r *AgentCardSyncReconciler) shouldSyncWorkload(labels map[string]string) bool { - if labels == nil { - return false - } - - // Must have kagenti.io/type=agent label - if labels[LabelAgentType] != LabelValueAgent { + if labels == nil || labels[LabelAgentType] != LabelValueAgent { return false } - - // Must have protocol label (new or old format) - if labels[LabelKagentiProtocol] != "" { - return true - } - - // Fall back to old label - if labels[LabelAgentProtocol] != "" { - return true - } - - return false + return labels[LabelKagentiProtocol] != "" || labels[LabelAgentProtocol] != "" } -// getAgentCardNameFromWorkload generates the AgentCard name from workload name and kind -// The kind is included to prevent collisions when a Deployment, StatefulSet, and/or -// legacy Agent CRD share the same name in a namespace. +// getAgentCardNameFromWorkload includes the kind suffix to prevent name collisions. func (r *AgentCardSyncReconciler) getAgentCardNameFromWorkload(workloadName string, kind string) string { return fmt.Sprintf("%s-%s-card", workloadName, strings.ToLower(kind)) } -// ensureAgentCard creates or updates an AgentCard for a workload using targetRef func (r *AgentCardSyncReconciler) ensureAgentCard(ctx context.Context, obj client.Object, gvk schema.GroupVersionKind) (ctrl.Result, error) { cardName := r.getAgentCardNameFromWorkload(obj.GetName(), gvk.Kind) - // Check if AgentCard already exists existingCard := &agentv1alpha1.AgentCard{} err := r.Get(ctx, types.NamespacedName{ Name: cardName, @@ -160,7 +130,19 @@ func (r *AgentCardSyncReconciler) ensureAgentCard(ctx context.Context, obj clien }, existingCard) if err == nil { - // Card exists - ensure owner reference is set and targetRef is correct + if r.isAutoCreatedCard(existingCard) { + if manualCard, found := r.findManualCardForWorkload(ctx, obj, gvk, cardName); found { + syncLogger.Info("Deleting auto-created AgentCard superseded by manual card", + "autoCard", cardName, "manualCard", manualCard, + "workload", obj.GetName(), "kind", gvk.Kind) + if err := r.Delete(ctx, existingCard); err != nil && !errors.IsNotFound(err) { + syncLogger.Error(err, "Failed to delete superseded auto-created AgentCard") + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + } + needsUpdate := false if !r.hasOwnerReferenceForObject(existingCard, obj) { @@ -173,14 +155,12 @@ func (r *AgentCardSyncReconciler) ensureAgentCard(ctx context.Context, obj clien needsUpdate = true } - // Ensure existing TargetRef matches the current workload. if existingCard.Spec.TargetRef != nil { tr := existingCard.Spec.TargetRef expectedAPIVersion := gvk.GroupVersion().String() expectedKind := gvk.Kind expectedName := obj.GetName() - // If TargetRef is effectively empty, initialize it to match the workload. if tr.APIVersion == "" && tr.Kind == "" && tr.Name == "" { syncLogger.Info("Initializing empty AgentCard TargetRef to match workload", "agentCard", cardName, @@ -192,14 +172,7 @@ func (r *AgentCardSyncReconciler) ensureAgentCard(ctx context.Context, obj clien tr.Name = expectedName needsUpdate = true } else if tr.APIVersion != expectedAPIVersion || tr.Kind != expectedKind || tr.Name != expectedName { - // Existing TargetRef points at a different workload; surface a clear conflict error. - errMsg := fmt.Sprintf( - "AgentCard TargetRef conflict for %s/%s: expected targetRef %s/%s %s, found %s/%s %s", - obj.GetNamespace(), cardName, - expectedAPIVersion, expectedKind, expectedName, - tr.APIVersion, tr.Kind, tr.Name, - ) - syncLogger.Error(nil, "AgentCard TargetRef does not match reconciled workload", + syncLogger.Error(fmt.Errorf("targetRef mismatch"), "AgentCard TargetRef does not match reconciled workload", "agentCard", cardName, "expectedAPIVersion", expectedAPIVersion, "expectedKind", expectedKind, @@ -207,7 +180,12 @@ func (r *AgentCardSyncReconciler) ensureAgentCard(ctx context.Context, obj clien "foundAPIVersion", tr.APIVersion, "foundKind", tr.Kind, "foundName", tr.Name) - return ctrl.Result{}, fmt.Errorf("%s", errMsg) + return ctrl.Result{}, fmt.Errorf( + "AgentCard TargetRef conflict for %s/%s: expected targetRef %s/%s %s, found %s/%s %s", + obj.GetNamespace(), cardName, + expectedAPIVersion, expectedKind, expectedName, + tr.APIVersion, tr.Kind, tr.Name, + ) } } if needsUpdate { @@ -224,9 +202,6 @@ func (r *AgentCardSyncReconciler) ensureAgentCard(ctx context.Context, obj clien return ctrl.Result{}, err } - // Before creating, check if another AgentCard already targets this workload. - // This prevents duplicates when a user manually creates an AgentCard with a custom name - // (e.g. "weather-card") that targets the same Deployment/StatefulSet. if existingCard, found := r.findExistingCardForWorkload(ctx, obj, gvk); found { syncLogger.Info("Skipping auto-creation: another AgentCard already targets this workload", "existingCard", existingCard, @@ -235,10 +210,6 @@ func (r *AgentCardSyncReconciler) ensureAgentCard(ctx context.Context, obj clien return ctrl.Result{}, nil } - // Grace period for newly created workloads: when a Deployment and an AgentCard are - // applied together (e.g. in the same kubectl apply), the Deployment event can trigger - // the sync reconciler before the manually-created AgentCard appears in the informer - // cache. Requeue once so the duplicate check above can catch it on the next pass. gracePeriod := r.getAutoSyncGracePeriod() if gracePeriod > 0 && time.Since(obj.GetCreationTimestamp().Time) < gracePeriod { syncLogger.V(1).Info("Workload recently created, requeueing before auto-creating AgentCard", @@ -247,41 +218,59 @@ func (r *AgentCardSyncReconciler) ensureAgentCard(ctx context.Context, obj clien return ctrl.Result{RequeueAfter: gracePeriod}, nil } - // Create new AgentCard with targetRef return r.createAgentCardForWorkload(ctx, obj, gvk, cardName) } -// findExistingCardForWorkload checks if any AgentCard in the namespace already targets -// the given workload via targetRef. Returns the card name and true if found. This -// prevents auto-sync from creating duplicate cards when a user has manually created -// an AgentCard with a custom name. -// -// Uses the shared TargetRefNameIndex to narrow the list to cards referencing the -// same workload name, then verifies apiVersion and kind. +// findExistingCardForWorkload returns the name of an existing card targeting this workload, if any. func (r *AgentCardSyncReconciler) findExistingCardForWorkload(ctx context.Context, obj client.Object, gvk schema.GroupVersionKind) (string, bool) { + return r.findCardForWorkload(ctx, obj, gvk, "") +} + +// findManualCardForWorkload returns the name of a manually-created card targeting this workload, +// excluding the auto-created card identified by excludeName. +func (r *AgentCardSyncReconciler) findManualCardForWorkload(ctx context.Context, obj client.Object, gvk schema.GroupVersionKind, excludeName string) (string, bool) { + return r.findCardForWorkload(ctx, obj, gvk, excludeName) +} + +// findCardForWorkload lists cards targeting the workload, optionally excluding one by name. +// Uses the targetRef.name field index when available, falling back to a full namespace list. +func (r *AgentCardSyncReconciler) findCardForWorkload(ctx context.Context, obj client.Object, gvk schema.GroupVersionKind, excludeName string) (string, bool) { cardList := &agentv1alpha1.AgentCardList{} - if err := r.List(ctx, cardList, + + err := r.List(ctx, cardList, client.InNamespace(obj.GetNamespace()), client.MatchingFields{TargetRefNameIndex: obj.GetName()}, - ); err != nil { - syncLogger.Error(err, "Failed to list AgentCards for duplicate check") - return "", false + ) + if err != nil { + // Field index may not be available (e.g. unit tests); fall back to unindexed list. + cardList = &agentv1alpha1.AgentCardList{} + if fallbackErr := r.List(ctx, cardList, client.InNamespace(obj.GetNamespace())); fallbackErr != nil { + syncLogger.Error(fallbackErr, "Failed to list AgentCards for duplicate check") + return "", false + } } expectedAPIVersion := gvk.GroupVersion().String() for i := range cardList.Items { card := &cardList.Items[i] - + if card.Name == excludeName { + continue + } if card.Spec.TargetRef != nil && card.Spec.TargetRef.APIVersion == expectedAPIVersion && - card.Spec.TargetRef.Kind == gvk.Kind { + card.Spec.TargetRef.Kind == gvk.Kind && + card.Spec.TargetRef.Name == obj.GetName() { return card.Name, true } } return "", false } +func (r *AgentCardSyncReconciler) isAutoCreatedCard(card *agentv1alpha1.AgentCard) bool { + return card.Labels != nil && card.Labels[LabelManagedBy] == LabelManagedByValue +} + // createAgentCardForWorkload creates a new AgentCard for a workload using targetRef func (r *AgentCardSyncReconciler) createAgentCardForWorkload(ctx context.Context, obj client.Object, gvk schema.GroupVersionKind, cardName string) (ctrl.Result, error) { syncLogger.Info("Creating AgentCard for workload", @@ -300,8 +289,8 @@ func (r *AgentCardSyncReconciler) createAgentCardForWorkload(ctx context.Context Name: cardName, Namespace: obj.GetNamespace(), Labels: map[string]string{ - "app.kubernetes.io/name": appName, - "app.kubernetes.io/managed-by": "kagenti-operator", + "app.kubernetes.io/name": appName, + LabelManagedBy: LabelManagedByValue, }, }, Spec: agentv1alpha1.AgentCardSpec{ @@ -314,7 +303,6 @@ func (r *AgentCardSyncReconciler) createAgentCardForWorkload(ctx context.Context }, } - // Set owner reference for garbage collection if err := controllerutil.SetControllerReference(obj, agentCard, r.Scheme); err != nil { syncLogger.Error(err, "Failed to set controller reference for AgentCard") return ctrl.Result{}, err @@ -322,8 +310,6 @@ func (r *AgentCardSyncReconciler) createAgentCardForWorkload(ctx context.Context if err := r.Create(ctx, agentCard); err != nil { if errors.IsAlreadyExists(err) { - // Re-fetch the existing AgentCard and validate/repair it - // This handles race conditions and ensures the card belongs to this workload return r.handleAlreadyExistsOnCreate(ctx, obj, gvk, cardName) } syncLogger.Error(err, "Failed to create AgentCard") @@ -334,9 +320,6 @@ func (r *AgentCardSyncReconciler) createAgentCardForWorkload(ctx context.Context return ctrl.Result{}, nil } -// handleAlreadyExistsOnCreate handles the case where an AgentCard already exists during create. -// It re-fetches the existing card and validates that its targetRef matches the workload we're -// reconciling. If there's a mismatch, it returns a conflict error to make the misconfiguration visible. func (r *AgentCardSyncReconciler) handleAlreadyExistsOnCreate(ctx context.Context, obj client.Object, gvk schema.GroupVersionKind, cardName string) (ctrl.Result, error) { syncLogger.Info("AgentCard already exists, validating ownership", "agentCard", cardName) @@ -345,7 +328,6 @@ func (r *AgentCardSyncReconciler) handleAlreadyExistsOnCreate(ctx context.Contex Name: cardName, Namespace: obj.GetNamespace(), }, existingCard); err != nil { - // Card was deleted between create attempt and re-fetch, requeue to retry if errors.IsNotFound(err) { syncLogger.Info("AgentCard was deleted after AlreadyExists, requeueing", "agentCard", cardName) return ctrl.Result{Requeue: true}, nil @@ -357,18 +339,10 @@ func (r *AgentCardSyncReconciler) handleAlreadyExistsOnCreate(ctx context.Contex expectedKind := gvk.Kind expectedName := obj.GetName() - // Check if the existing card's targetRef matches our workload if existingCard.Spec.TargetRef != nil { tr := existingCard.Spec.TargetRef if tr.APIVersion != expectedAPIVersion || tr.Kind != expectedKind || tr.Name != expectedName { - // Conflict: existing AgentCard belongs to a different workload - errMsg := fmt.Sprintf( - "AgentCard %s/%s already exists with conflicting targetRef: expected %s/%s %s, found %s/%s %s", - obj.GetNamespace(), cardName, - expectedAPIVersion, expectedKind, expectedName, - tr.APIVersion, tr.Kind, tr.Name, - ) - syncLogger.Error(nil, "AgentCard targetRef conflict detected", + syncLogger.Error(fmt.Errorf("targetRef conflict"), "AgentCard targetRef conflict detected", "agentCard", cardName, "expectedAPIVersion", expectedAPIVersion, "expectedKind", expectedKind, @@ -376,12 +350,15 @@ func (r *AgentCardSyncReconciler) handleAlreadyExistsOnCreate(ctx context.Contex "foundAPIVersion", tr.APIVersion, "foundKind", tr.Kind, "foundName", tr.Name) - return ctrl.Result{}, fmt.Errorf("%s", errMsg) + return ctrl.Result{}, fmt.Errorf( + "AgentCard %s/%s already exists with conflicting targetRef: expected %s/%s %s, found %s/%s %s", + obj.GetNamespace(), cardName, + expectedAPIVersion, expectedKind, expectedName, + tr.APIVersion, tr.Kind, tr.Name, + ) } } - // Card exists and either has no targetRef or matches our workload - // Ensure owner reference is set correctly if !r.hasOwnerReferenceForObject(existingCard, obj) { syncLogger.Info("Adding owner reference to existing AgentCard", "agentCard", cardName, "owner", obj.GetName(), "kind", gvk.Kind) @@ -390,7 +367,6 @@ func (r *AgentCardSyncReconciler) handleAlreadyExistsOnCreate(ctx context.Contex return ctrl.Result{}, err } - // Update targetRef if not set if existingCard.Spec.TargetRef == nil { existingCard.Spec.TargetRef = &agentv1alpha1.TargetRef{ APIVersion: expectedAPIVersion, @@ -409,7 +385,6 @@ func (r *AgentCardSyncReconciler) handleAlreadyExistsOnCreate(ctx context.Contex return ctrl.Result{}, nil } -// hasOwnerReferenceForObject checks if an AgentCard has the correct owner reference for any object func (r *AgentCardSyncReconciler) hasOwnerReferenceForObject(agentCard *agentv1alpha1.AgentCard, obj client.Object) bool { for _, ownerRef := range agentCard.OwnerReferences { if ownerRef.UID == obj.GetUID() { @@ -419,15 +394,11 @@ func (r *AgentCardSyncReconciler) hasOwnerReferenceForObject(agentCard *agentv1a return false } -// SetupWithManager sets up the controller with the Manager. -// It creates separate controllers for Deployments and StatefulSets. func (r *AgentCardSyncReconciler) SetupWithManager(mgr ctrl.Manager) error { - // Register the shared field indexer (safe to call from multiple controllers). if err := RegisterAgentCardTargetRefIndex(mgr); err != nil { return err } - // Watch Deployments with agent labels if err := ctrl.NewControllerManagedBy(mgr). Named("agentcardsync-deployment"). For(&appsv1.Deployment{}). @@ -436,7 +407,6 @@ func (r *AgentCardSyncReconciler) SetupWithManager(mgr ctrl.Manager) error { return err } - // Watch StatefulSets with agent labels if err := ctrl.NewControllerManagedBy(mgr). Named("agentcardsync-statefulset"). For(&appsv1.StatefulSet{}). @@ -448,7 +418,6 @@ func (r *AgentCardSyncReconciler) SetupWithManager(mgr ctrl.Manager) error { return nil } -// deploymentReconcilerAdapter adapts AgentCardSyncReconciler to handle Deployment reconcile requests type deploymentReconcilerAdapter struct { *AgentCardSyncReconciler } @@ -457,7 +426,6 @@ func (a *deploymentReconcilerAdapter) Reconcile(ctx context.Context, req ctrl.Re return a.ReconcileDeployment(ctx, req) } -// statefulSetReconcilerAdapter adapts AgentCardSyncReconciler to handle StatefulSet reconcile requests type statefulSetReconcilerAdapter struct { *AgentCardSyncReconciler } diff --git a/kagenti-operator/internal/controller/agentcardsync_controller_test.go b/kagenti-operator/internal/controller/agentcardsync_controller_test.go index e8a6e22c..519ea0fb 100644 --- a/kagenti-operator/internal/controller/agentcardsync_controller_test.go +++ b/kagenti-operator/internal/controller/agentcardsync_controller_test.go @@ -207,6 +207,150 @@ var _ = Describe("AgentCardSync Controller", func() { }) }) + Context("When a manual AgentCard targets the same workload as an auto-created one", func() { + const ( + deploymentName = "test-supersede-deployment" + autoCardName = "test-supersede-deployment-deployment-card" + manualCardName = "test-supersede-manual" + namespace = "default" + ) + + ctx := context.Background() + + deploymentNN := types.NamespacedName{Name: deploymentName, Namespace: namespace} + + BeforeEach(func() { + By("creating a labelled Deployment") + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: deploymentName, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/name": deploymentName, + LabelAgentType: LabelValueAgent, + LabelKagentiProtocol: "a2a", + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": deploymentName}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": deploymentName}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "agent", Image: "test:latest"}}, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, deployment)).To(Succeed()) + }) + + AfterEach(func() { + deployment := &appsv1.Deployment{} + if err := k8sClient.Get(ctx, deploymentNN, deployment); err == nil { + Expect(k8sClient.Delete(ctx, deployment)).To(Succeed()) + } + for _, name := range []string{autoCardName, manualCardName} { + card := &agentv1alpha1.AgentCard{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, card); err == nil { + Expect(k8sClient.Delete(ctx, card)).To(Succeed()) + } + } + }) + + It("should delete the auto-created card when a manual card exists", func() { + reconciler := &AgentCardSyncReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AutoSyncGracePeriod: -1, + } + + By("auto-creating the card via first reconcile") + _, err := reconciler.ReconcileDeployment(ctx, reconcile.Request{NamespacedName: deploymentNN}) + Expect(err).NotTo(HaveOccurred()) + + autoCard := &agentv1alpha1.AgentCard{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: autoCardName, Namespace: namespace}, autoCard)).To(Succeed()) + + By("creating a manual AgentCard targeting the same Deployment") + manualCard := &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{ + Name: manualCardName, + Namespace: namespace, + }, + Spec: agentv1alpha1.AgentCardSpec{ + TargetRef: &agentv1alpha1.TargetRef{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: deploymentName, + }, + }, + } + Expect(k8sClient.Create(ctx, manualCard)).To(Succeed()) + + By("reconciling again -- auto-card should be deleted") + _, err = reconciler.ReconcileDeployment(ctx, reconcile.Request{NamespacedName: deploymentNN}) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, types.NamespacedName{Name: autoCardName, Namespace: namespace}, autoCard) + Expect(errors.IsNotFound(err)).To(BeTrue(), "auto-created card should have been deleted") + + By("verifying the manual card still exists") + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: manualCardName, Namespace: namespace}, manualCard)).To(Succeed()) + }) + + It("should not delete a manually-created card even if another manual card exists", func() { + By("creating two manual AgentCards targeting the same Deployment") + for _, name := range []string{manualCardName, manualCardName + "-2"} { + card := &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: agentv1alpha1.AgentCardSpec{ + TargetRef: &agentv1alpha1.TargetRef{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: deploymentName, + }, + }, + } + Expect(k8sClient.Create(ctx, card)).To(Succeed()) + } + + reconciler := &AgentCardSyncReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AutoSyncGracePeriod: -1, + } + + By("reconciling -- sync controller should skip creation, not delete manual cards") + _, err := reconciler.ReconcileDeployment(ctx, reconcile.Request{NamespacedName: deploymentNN}) + Expect(err).NotTo(HaveOccurred()) + + By("verifying both manual cards still exist") + for _, name := range []string{manualCardName, manualCardName + "-2"} { + card := &agentv1alpha1.AgentCard{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, card)).To(Succeed()) + } + + By("verifying no auto-card was created") + autoCard := &agentv1alpha1.AgentCard{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: autoCardName, Namespace: namespace}, autoCard) + Expect(errors.IsNotFound(err)).To(BeTrue()) + + By("cleaning up second manual card") + card := &agentv1alpha1.AgentCard{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: manualCardName + "-2", Namespace: namespace}, card); err == nil { + Expect(k8sClient.Delete(ctx, card)).To(Succeed()) + } + }) + }) + Context("When reconciling a StatefulSet with agent labels", func() { const ( statefulsetName = "test-sync-statefulset" @@ -316,5 +460,4 @@ var _ = Describe("AgentCardSync Controller", func() { }) }) - // selector-to-targetRef migration test removed — migration code deleted in targetRef migration. }) diff --git a/kagenti-operator/internal/controller/identity_binding_test.go b/kagenti-operator/internal/controller/identity_binding_test.go index e5cb927d..063ebbca 100644 --- a/kagenti-operator/internal/controller/identity_binding_test.go +++ b/kagenti-operator/internal/controller/identity_binding_test.go @@ -18,579 +18,213 @@ package controller import ( "context" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" - "github.com/kagenti/operator/internal/signature" ) -var _ = Describe("Identity Binding", func() { - const ( - timeout = time.Second * 10 - interval = time.Millisecond * 250 - ) - - Context("AgentCard Binding Evaluation - Matching", func() { - const ( - deploymentName = "bind-eval-match-deploy" - agentCardName = "bind-eval-match-card" - secretName = "bind-eval-match-keys" - namespace = "default" - trustDomain = "test.local" - ) - - ctx := context.Background() - - AfterEach(func() { - By("cleaning up test resources") - cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) - cleanupResource(ctx, &appsv1.Deployment{}, deploymentName, namespace) - cleanupResource(ctx, &corev1.Service{}, deploymentName, namespace) - cleanupResource(ctx, &corev1.Secret{}, secretName, namespace) - }) - - It("should evaluate binding as Bound when SPIFFE ID matches allowlist", func() { - By("generating an RSA key pair") - privKey, err := rsa.GenerateKey(rand.Reader, 2048) - Expect(err).NotTo(HaveOccurred()) - pubDER, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) - Expect(err).NotTo(HaveOccurred()) - pubKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}) - - By("creating the public key Secret") - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: namespace}, - Data: map[string][]byte{"signing-key": pubKeyPEM}, - } - Expect(k8sClient.Create(ctx, secret)).To(Succeed()) +var _ = Describe("Identity Binding — Trust Domain Only", func() { - By("creating a Deployment with agent labels") - labels := map[string]string{ - "app.kubernetes.io/name": deploymentName, - LabelAgentType: LabelValueAgent, - LabelKagentiProtocol: "a2a", - } - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: deploymentName, - Namespace: namespace, - Labels: labels, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": deploymentName}}, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": deploymentName}}, - Spec: corev1.PodSpec{ - ServiceAccountName: "test-sa", - Containers: []corev1.Container{ - {Name: "agent", Image: "test-image:latest"}, - }, - }, - }, - }, - } - Expect(k8sClient.Create(ctx, deployment)).To(Succeed()) - - By("setting Deployment status to Available") - Eventually(func() error { - if err := k8sClient.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: namespace}, deployment); err != nil { - return err - } - deployment.Status.Conditions = []appsv1.DeploymentCondition{ - {Type: appsv1.DeploymentAvailable, Status: corev1.ConditionTrue}, - } - return k8sClient.Status().Update(ctx, deployment) - }).Should(Succeed()) - - By("creating a Service for the Deployment") - service := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: deploymentName, - Namespace: namespace, - }, - Spec: corev1.ServiceSpec{ - Ports: []corev1.ServicePort{ - {Name: "http", Port: 8000, Protocol: corev1.ProtocolTCP}, - }, - Selector: map[string]string{"app": deploymentName}, - }, - } - Expect(k8sClient.Create(ctx, service)).To(Succeed()) - - By("creating signed card data with SPIFFE ID in JWS protected header") - expectedSpiffeID := "spiffe://" + trustDomain + "/ns/" + namespace + "/sa/test-sa" - cardData := &agentv1alpha1.AgentCardData{ - Name: "Test Agent", - Version: "1.0.0", - URL: "http://localhost:8000", + Context("computeBinding unit tests", func() { + It("should return nil when identityBinding is not configured", func() { + reconciler := &AgentCardReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + SpireTrustDomain: "example.org", } - jwsSig := buildTestJWS(cardData, privKey, "key-1", expectedSpiffeID) - cardData.Signatures = []agentv1alpha1.AgentCardSignature{jwsSig} - By("creating an AgentCard with identity binding") agentCard := &agentv1alpha1.AgentCard{ - ObjectMeta: metav1.ObjectMeta{ - Name: agentCardName, - Namespace: namespace, - }, - Spec: agentv1alpha1.AgentCardSpec{ - SyncPeriod: "30s", - TargetRef: &agentv1alpha1.TargetRef{ - APIVersion: "apps/v1", - Kind: "Deployment", - Name: deploymentName, - }, - IdentityBinding: &agentv1alpha1.IdentityBinding{ - AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{agentv1alpha1.SpiffeID(expectedSpiffeID)}, - Strict: false, - }, - }, - } - Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) - - By("setting up reconciler with signature verification") - provider, err := signature.NewSecretProvider(&signature.Config{ - Type: signature.ProviderTypeSecret, - SecretName: secretName, - SecretNamespace: namespace, - }) - Expect(err).NotTo(HaveOccurred()) - provider.(*signature.SecretProvider).SetClient(k8sClient) - - reconciler := &AgentCardReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - AgentFetcher: &mockFetcher{cardData: cardData}, - RequireSignature: true, - SignatureProvider: provider, + Spec: agentv1alpha1.AgentCardSpec{}, } - By("reconciling the AgentCard (first reconcile adds finalizer)") - _, err = reconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, - }) - Expect(err).NotTo(HaveOccurred()) - - By("reconciling again (verifies signature and evaluates binding in one pass)") - _, err = reconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, - }) - Expect(err).NotTo(HaveOccurred()) - - By("verifying binding status is Bound") - Eventually(func() bool { - card := &agentv1alpha1.AgentCard{} - if err := k8sClient.Get(ctx, types.NamespacedName{Name: agentCardName, Namespace: namespace}, card); err != nil { - return false - } - return card.Status.BindingStatus != nil && card.Status.BindingStatus.Bound - }, timeout, interval).Should(BeTrue()) - - By("verifying expected SPIFFE ID is set") - card := &agentv1alpha1.AgentCard{} - Expect(k8sClient.Get(ctx, types.NamespacedName{Name: agentCardName, Namespace: namespace}, card)).To(Succeed()) - Expect(card.Status.ExpectedSpiffeID).To(Equal(expectedSpiffeID)) - Expect(card.Status.BindingStatus.Reason).To(Equal(ReasonBound)) + result := reconciler.computeBinding(agentCard, "spiffe://example.org/ns/default/sa/test") + Expect(result).To(BeNil()) }) - }) - - Context("AgentCard Binding Evaluation - NonMatching", func() { - const ( - deploymentName = "bind-eval-nomatch-deploy" - agentCardName = "bind-eval-nomatch-card" - secretName = "bind-eval-nomatch-keys" - namespace = "default" - trustDomain = "test.local" - ) - - ctx := context.Background() - - AfterEach(func() { - By("cleaning up test resources") - cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) - cleanupResource(ctx, &appsv1.Deployment{}, deploymentName, namespace) - cleanupResource(ctx, &corev1.Service{}, deploymentName, namespace) - cleanupResource(ctx, &corev1.Secret{}, secretName, namespace) - }) - - It("should evaluate binding as NotBound when SPIFFE ID is not in allowlist", func() { - By("generating an RSA key pair") - privKey, err := rsa.GenerateKey(rand.Reader, 2048) - Expect(err).NotTo(HaveOccurred()) - pubDER, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) - Expect(err).NotTo(HaveOccurred()) - pubKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}) - - By("creating the public key Secret") - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: namespace}, - Data: map[string][]byte{"signing-key": pubKeyPEM}, - } - Expect(k8sClient.Create(ctx, secret)).To(Succeed()) - - By("creating a Deployment with agent labels") - labels := map[string]string{ - "app.kubernetes.io/name": deploymentName, - LabelAgentType: LabelValueAgent, - LabelKagentiProtocol: "a2a", - } - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: deploymentName, - Namespace: namespace, - Labels: labels, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": deploymentName}}, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": deploymentName}}, - Spec: corev1.PodSpec{ - ServiceAccountName: "test-sa", - Containers: []corev1.Container{ - {Name: "agent", Image: "test-image:latest"}, - }, - }, - }, - }, - } - Expect(k8sClient.Create(ctx, deployment)).To(Succeed()) - - By("setting Deployment status to Available") - Eventually(func() error { - if err := k8sClient.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: namespace}, deployment); err != nil { - return err - } - deployment.Status.Conditions = []appsv1.DeploymentCondition{ - {Type: appsv1.DeploymentAvailable, Status: corev1.ConditionTrue}, - } - return k8sClient.Status().Update(ctx, deployment) - }).Should(Succeed()) - - By("creating a Service for the Deployment") - service := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: deploymentName, - Namespace: namespace, - }, - Spec: corev1.ServiceSpec{ - Ports: []corev1.ServicePort{ - {Name: "http", Port: 8000, Protocol: corev1.ProtocolTCP}, - }, - Selector: map[string]string{"app": deploymentName}, - }, - } - Expect(k8sClient.Create(ctx, service)).To(Succeed()) - - By("creating signed card data with SPIFFE ID that doesn't match allowlist") - // JWS SPIFFE ID will NOT match the allowlist → binding should fail - jwsSpiffeID := "spiffe://" + trustDomain + "/ns/" + namespace + "/sa/test-sa" - cardData := &agentv1alpha1.AgentCardData{ - Name: "Test Agent", - Version: "1.0.0", - URL: "http://localhost:8000", + It("should bind when SPIFFE ID matches operator trust domain", func() { + reconciler := &AgentCardReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + SpireTrustDomain: "example.org", } - jwsSig := buildTestJWS(cardData, privKey, "key-1", jwsSpiffeID) - cardData.Signatures = []agentv1alpha1.AgentCardSignature{jwsSig} - By("creating an AgentCard with identity binding (allowlist does NOT include the JWS SPIFFE ID)") agentCard := &agentv1alpha1.AgentCard{ - ObjectMeta: metav1.ObjectMeta{ - Name: agentCardName, - Namespace: namespace, - }, Spec: agentv1alpha1.AgentCardSpec{ - SyncPeriod: "30s", - TargetRef: &agentv1alpha1.TargetRef{ - APIVersion: "apps/v1", - Kind: "Deployment", - Name: deploymentName, - }, - IdentityBinding: &agentv1alpha1.IdentityBinding{ - AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{"spiffe://" + trustDomain + "/ns/other/sa/other-sa"}, - Strict: false, - }, + IdentityBinding: &agentv1alpha1.IdentityBinding{}, }, } - Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) - By("setting up reconciler with signature verification") - provider, err := signature.NewSecretProvider(&signature.Config{ - Type: signature.ProviderTypeSecret, - SecretName: secretName, - SecretNamespace: namespace, - }) - Expect(err).NotTo(HaveOccurred()) - provider.(*signature.SecretProvider).SetClient(k8sClient) - - reconciler := &AgentCardReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - AgentFetcher: &mockFetcher{cardData: cardData}, - RequireSignature: true, - SignatureProvider: provider, - } - - By("reconciling the AgentCard (first reconcile adds finalizer)") - _, err = reconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, - }) - Expect(err).NotTo(HaveOccurred()) - - By("reconciling again (verifies signature and evaluates binding — SPIFFE ID not in allowlist)") - _, err = reconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, - }) - Expect(err).NotTo(HaveOccurred()) - - By("verifying binding status is NotBound") - Eventually(func() bool { - card := &agentv1alpha1.AgentCard{} - if err := k8sClient.Get(ctx, types.NamespacedName{Name: agentCardName, Namespace: namespace}, card); err != nil { - return false - } - return card.Status.BindingStatus != nil && !card.Status.BindingStatus.Bound - }, timeout, interval).Should(BeTrue()) - - By("verifying reason is NotBound") - card := &agentv1alpha1.AgentCard{} - Expect(k8sClient.Get(ctx, types.NamespacedName{Name: agentCardName, Namespace: namespace}, card)).To(Succeed()) - Expect(card.Status.BindingStatus.Reason).To(Equal(ReasonNotBound)) + result := reconciler.computeBinding(agentCard, "spiffe://example.org/ns/default/sa/agent") + Expect(result).NotTo(BeNil()) + Expect(result.Bound).To(BeTrue()) + Expect(result.Reason).To(Equal(ReasonBound)) }) - }) - Context("Card ID Drift Detection", func() { - It("should compute consistent card ID for same card data", func() { + It("should not bind when SPIFFE ID belongs to wrong trust domain", func() { reconciler := &AgentCardReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + Client: k8sClient, + Scheme: k8sClient.Scheme(), + SpireTrustDomain: "example.org", } - cardData := &agentv1alpha1.AgentCardData{ - Name: "Test Agent", - Description: "A test agent", - Version: "1.0.0", - URL: "http://localhost:8000", + agentCard := &agentv1alpha1.AgentCard{ + Spec: agentv1alpha1.AgentCardSpec{ + IdentityBinding: &agentv1alpha1.IdentityBinding{}, + }, } - cardId1 := reconciler.computeCardId(cardData) - cardId2 := reconciler.computeCardId(cardData) - - Expect(cardId1).NotTo(BeEmpty()) - Expect(cardId1).To(Equal(cardId2)) + result := reconciler.computeBinding(agentCard, "spiffe://evil.com/ns/default/sa/agent") + Expect(result).NotTo(BeNil()) + Expect(result.Bound).To(BeFalse()) + Expect(result.Reason).To(Equal(ReasonNotBound)) }) - It("should compute different card ID for different card data", func() { + It("should use per-card trust domain override", func() { reconciler := &AgentCardReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - } - - cardData1 := &agentv1alpha1.AgentCardData{ - Name: "Test Agent", - Version: "1.0.0", + Client: k8sClient, + Scheme: k8sClient.Scheme(), + SpireTrustDomain: "default-domain.org", } - cardData2 := &agentv1alpha1.AgentCardData{ - Name: "Test Agent", - Version: "2.0.0", + agentCard := &agentv1alpha1.AgentCard{ + Spec: agentv1alpha1.AgentCardSpec{ + IdentityBinding: &agentv1alpha1.IdentityBinding{ + TrustDomain: "override-domain.org", + }, + }, } - cardId1 := reconciler.computeCardId(cardData1) - cardId2 := reconciler.computeCardId(cardData2) + result := reconciler.computeBinding(agentCard, "spiffe://override-domain.org/ns/default/sa/agent") + Expect(result).NotTo(BeNil()) + Expect(result.Bound).To(BeTrue()) - Expect(cardId1).NotTo(BeEmpty()) - Expect(cardId2).NotTo(BeEmpty()) - Expect(cardId1).NotTo(Equal(cardId2)) + resultWrong := reconciler.computeBinding(agentCard, "spiffe://default-domain.org/ns/default/sa/agent") + Expect(resultWrong).NotTo(BeNil()) + Expect(resultWrong.Bound).To(BeFalse()) }) - }) - Context("SPIFFE ID Source — JWS Protected Header Only", func() { - It("should fail binding when no SPIFFE ID is in the JWS protected header", func() { + It("should fail binding when no SPIFFE ID is provided", func() { reconciler := &AgentCardReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + Client: k8sClient, + Scheme: k8sClient.Scheme(), + SpireTrustDomain: "example.org", } agentCard := &agentv1alpha1.AgentCard{ - ObjectMeta: metav1.ObjectMeta{ - Name: "no-spiffe-card", - Namespace: "default", - }, Spec: agentv1alpha1.AgentCardSpec{ - IdentityBinding: &agentv1alpha1.IdentityBinding{ - AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{"spiffe://example.com/ns/default/sa/test"}, - }, + IdentityBinding: &agentv1alpha1.IdentityBinding{}, }, } - Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) - defer func() { - cleanupResource(ctx, &agentv1alpha1.AgentCard{}, "no-spiffe-card", "default") - }() - // No verified SPIFFE ID → binding fails result := reconciler.computeBinding(agentCard, "") Expect(result).NotTo(BeNil()) Expect(result.Bound).To(BeFalse()) }) - It("should bind when JWS SPIFFE ID matches the allowlist", func() { + It("should fail binding when no trust domain is configured", func() { reconciler := &AgentCardReconciler{ Client: k8sClient, Scheme: k8sClient.Scheme(), } - jwsSpiffeID := "spiffe://example.com/ns/default/sa/from-jws" - agentCard := &agentv1alpha1.AgentCard{ - ObjectMeta: metav1.ObjectMeta{ - Name: "jws-spiffe-card", - Namespace: "default", - }, Spec: agentv1alpha1.AgentCardSpec{ - IdentityBinding: &agentv1alpha1.IdentityBinding{ - AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{agentv1alpha1.SpiffeID(jwsSpiffeID)}, - }, + IdentityBinding: &agentv1alpha1.IdentityBinding{}, }, } - Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) - defer func() { - cleanupResource(ctx, &agentv1alpha1.AgentCard{}, "jws-spiffe-card", "default") - }() - // Verified SPIFFE ID matches allowlist → binding passes - result := reconciler.computeBinding(agentCard, jwsSpiffeID) + result := reconciler.computeBinding(agentCard, "spiffe://example.org/ns/default/sa/agent") Expect(result).NotTo(BeNil()) - Expect(result.Bound).To(BeTrue()) + Expect(result.Bound).To(BeFalse()) + Expect(result.Message).To(ContainSubstring("No trust domain configured")) }) - It("should bind when verified SPIFFE ID matches 2nd entry in allowlist", func() { + It("should not bind when SPIFFE ID exactly matches trust domain without path", func() { reconciler := &AgentCardReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + Client: k8sClient, + Scheme: k8sClient.Scheme(), + SpireTrustDomain: "example.org", } - verifiedSpiffeID := "spiffe://example.com/ns/default/sa/second-match" - agentCard := &agentv1alpha1.AgentCard{ - ObjectMeta: metav1.ObjectMeta{ - Name: "multi-allowlist-card", - Namespace: "default", - }, Spec: agentv1alpha1.AgentCardSpec{ - IdentityBinding: &agentv1alpha1.IdentityBinding{ - AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{ - "spiffe://example.com/ns/default/sa/first", - agentv1alpha1.SpiffeID(verifiedSpiffeID), - "spiffe://example.com/ns/default/sa/third", - }, - }, + IdentityBinding: &agentv1alpha1.IdentityBinding{}, }, } - Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) - defer func() { - cleanupResource(ctx, &agentv1alpha1.AgentCard{}, "multi-allowlist-card", "default") - }() - // Verified SPIFFE ID matches 2nd entry → binding passes - result := reconciler.computeBinding(agentCard, verifiedSpiffeID) + // spiffe://example.org/ with no path after the slash should not bind + result := reconciler.computeBinding(agentCard, "spiffe://example.org/") Expect(result).NotTo(BeNil()) - Expect(result.Bound).To(BeTrue()) + Expect(result.Bound).To(BeFalse()) }) + }) - It("should not bind when allowedSpiffeIDs is empty (bypassed validation)", func() { + Context("Card ID Drift Detection", func() { + It("should compute consistent card ID for same card data", func() { reconciler := &AgentCardReconciler{ Client: k8sClient, Scheme: k8sClient.Scheme(), } - // Craft an in-memory AgentCard with empty allowedSpiffeIDs — bypassing CRD validation - // to simulate a scenario where validation was somehow bypassed. - agentCard := &agentv1alpha1.AgentCard{ - ObjectMeta: metav1.ObjectMeta{ - Name: "empty-allowlist-card", - Namespace: "default", - }, - Spec: agentv1alpha1.AgentCardSpec{ - IdentityBinding: &agentv1alpha1.IdentityBinding{ - AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{}, - }, - }, + cardData := &agentv1alpha1.AgentCardData{ + Name: "Test Agent", + Description: "A test agent", + Version: "1.0.0", + URL: "http://localhost:8000", } - // Do NOT create via API — CRD enforces minItems=1. Test computeBinding directly. - // Empty allowlist should always fail binding with BUG log - result := reconciler.computeBinding(agentCard, "spiffe://example.com/ns/default/sa/test") - Expect(result).NotTo(BeNil()) - Expect(result.Bound).To(BeFalse()) - Expect(result.Message).To(ContainSubstring("allowedSpiffeIDs is empty")) + cardId1 := reconciler.computeCardId(cardData) + cardId2 := reconciler.computeCardId(cardData) + + Expect(cardId1).NotTo(BeEmpty()) + Expect(cardId1).To(Equal(cardId2)) }) - It("should not trust JWS SPIFFE ID when signature is invalid", func() { + It("should compute different card ID for different card data", func() { reconciler := &AgentCardReconciler{ Client: k8sClient, Scheme: k8sClient.Scheme(), } - jwsSpiffeID := "spiffe://example.com/ns/default/sa/from-jws" + cardData1 := &agentv1alpha1.AgentCardData{ + Name: "Test Agent", + Version: "1.0.0", + } - agentCard := &agentv1alpha1.AgentCard{ - ObjectMeta: metav1.ObjectMeta{ - Name: "invalid-sig-spiffe-card", - Namespace: "default", - }, - Spec: agentv1alpha1.AgentCardSpec{ - IdentityBinding: &agentv1alpha1.IdentityBinding{ - AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{agentv1alpha1.SpiffeID(jwsSpiffeID)}, - }, - }, + cardData2 := &agentv1alpha1.AgentCardData{ + Name: "Test Agent", + Version: "2.0.0", } - Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) - defer func() { - cleanupResource(ctx, &agentv1alpha1.AgentCard{}, "invalid-sig-spiffe-card", "default") - }() - // Invalid signature → caller passes empty string (never trusts unverified SPIFFE ID) → fails - result := reconciler.computeBinding(agentCard, "") - Expect(result).NotTo(BeNil()) - Expect(result.Bound).To(BeFalse()) + cardId1 := reconciler.computeCardId(cardData1) + cardId2 := reconciler.computeCardId(cardData2) + + Expect(cardId1).NotTo(BeEmpty()) + Expect(cardId2).NotTo(BeEmpty()) + Expect(cardId1).NotTo(Equal(cardId2)) }) }) - }) // cleanupResource removes a resource and waits for it to be fully deleted func cleanupResource(ctx context.Context, obj client.Object, name, namespace string) { key := types.NamespacedName{Name: name, Namespace: namespace} - // Try to get the object if err := k8sClient.Get(ctx, key, obj); err != nil { - return // Object doesn't exist, nothing to clean up + return } - // Remove finalizers to allow deletion obj.SetFinalizers(nil) _ = k8sClient.Update(ctx, obj) - - // Delete the object _ = k8sClient.Delete(ctx, obj) - // Wait for deletion to complete Eventually(func() bool { err := k8sClient.Get(ctx, key, obj) - return err != nil // Returns true when object is gone + return err != nil }, time.Second*5, time.Millisecond*100).Should(BeTrue()) } diff --git a/kagenti-operator/internal/controller/indexers.go b/kagenti-operator/internal/controller/indexers.go index 92398b87..a6f5ef94 100644 --- a/kagenti-operator/internal/controller/indexers.go +++ b/kagenti-operator/internal/controller/indexers.go @@ -21,27 +21,23 @@ import ( "fmt" "sync" + "github.com/go-logr/logr" agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" ) -// TargetRefNameIndex is the shared field index key for .spec.targetRef.name. -// Used by AgentCardReconciler, AgentCardNetworkPolicyReconciler, and -// AgentCardSyncReconciler to look up AgentCards by targetRef name without -// listing every card in the namespace. const TargetRefNameIndex = ".spec.targetRef.name" -// registerTargetRefIndexOnce ensures the field indexer is registered exactly once, -// even if multiple controllers call RegisterAgentCardTargetRefIndex. -var registerTargetRefIndexOnce sync.Once - -// registerTargetRefIndexErr stores any error from the one-time registration. -var registerTargetRefIndexErr error +var ( + registerTargetRefIndexOnce sync.Once + registerTargetRefIndexErr error +) -// RegisterAgentCardTargetRefIndex registers a field indexer for AgentCard on -// .spec.targetRef.name. It is safe to call from multiple controllers — only -// the first call performs the registration; subsequent calls are no-ops. +// RegisterAgentCardTargetRefIndex registers a field indexer on .spec.targetRef.name (idempotent). func RegisterAgentCardTargetRefIndex(mgr ctrl.Manager) error { registerTargetRefIndexOnce.Do(func() { registerTargetRefIndexErr = mgr.GetFieldIndexer().IndexField( @@ -49,7 +45,10 @@ func RegisterAgentCardTargetRefIndex(mgr ctrl.Manager) error { &agentv1alpha1.AgentCard{}, TargetRefNameIndex, func(obj client.Object) []string { - card := obj.(*agentv1alpha1.AgentCard) + card, ok := obj.(*agentv1alpha1.AgentCard) + if !ok { + return nil + } if card.Spec.TargetRef != nil && card.Spec.TargetRef.Name != "" { return []string{card.Spec.TargetRef.Name} } @@ -63,3 +62,35 @@ func RegisterAgentCardTargetRefIndex(mgr ctrl.Manager) error { }) return registerTargetRefIndexErr } + +func mapWorkloadToAgentCards(lister client.Reader, apiVersion, kind string, log logr.Logger) handler.MapFunc { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + if !isAgentWorkload(obj.GetLabels()) { + return nil + } + + agentCardList := &agentv1alpha1.AgentCardList{} + if err := lister.List(ctx, agentCardList, + client.InNamespace(obj.GetNamespace()), + client.MatchingFields{TargetRefNameIndex: obj.GetName()}, + ); err != nil { + log.Error(err, "Failed to list AgentCards for mapping") + return nil + } + + var requests []reconcile.Request + for _, card := range agentCardList.Items { + if card.Spec.TargetRef != nil && + card.Spec.TargetRef.Kind == kind && + card.Spec.TargetRef.APIVersion == apiVersion { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: card.Name, + Namespace: card.Namespace, + }, + }) + } + } + return requests + } +} diff --git a/kagenti-operator/internal/controller/signature_verification_test.go b/kagenti-operator/internal/controller/signature_verification_test.go index 96e3e2a1..17e29c1e 100644 --- a/kagenti-operator/internal/controller/signature_verification_test.go +++ b/kagenti-operator/internal/controller/signature_verification_test.go @@ -51,7 +51,6 @@ var _ = Describe("Signature Verification", func() { deploymentName = "sig-valid-agent" agentCardName = "sig-valid-card" namespace = "default" - secretName = "sig-valid-keys" ) var ( @@ -63,25 +62,7 @@ var _ = Describe("Signature Verification", func() { BeforeEach(func() { By("generating an RSA key pair") - var err error - rsaPrivKey, err = rsa.GenerateKey(rand.Reader, 2048) - Expect(err).NotTo(HaveOccurred()) - - pubDER, err := x509.MarshalPKIXPublicKey(&rsaPrivKey.PublicKey) - Expect(err).NotTo(HaveOccurred()) - pubKeyPEM = pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}) - - By("creating the public key Secret") - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: namespace, - }, - Data: map[string][]byte{ - "my-signing-key": pubKeyPEM, - }, - } - Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + rsaPrivKey, pubKeyPEM = generateTestRSAKeyPair() }) AfterEach(func() { @@ -89,7 +70,6 @@ var _ = Describe("Signature Verification", func() { cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) cleanupResource(ctx, &appsv1.Deployment{}, deploymentName, namespace) cleanupResource(ctx, &corev1.Service{}, deploymentName, namespace) - cleanupResource(ctx, &corev1.Secret{}, secretName, namespace) }) It("should set validSignature=true and SignatureVerified condition for a correctly signed card", func() { @@ -120,13 +100,7 @@ var _ = Describe("Signature Verification", func() { Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) By("configuring a reconciler with signature verification enabled") - provider, err := signature.NewSecretProvider(&signature.Config{ - Type: signature.ProviderTypeSecret, - SecretName: secretName, - SecretNamespace: namespace, - }) - Expect(err).NotTo(HaveOccurred()) - provider.(*signature.SecretProvider).SetClient(k8sClient) + provider := &mockSignatureProvider{pubKeyPEM: pubKeyPEM} reconciler := &AgentCardReconciler{ Client: k8sClient, @@ -138,7 +112,7 @@ var _ = Describe("Signature Verification", func() { } // First reconcile adds finalizer - _, err = reconciler.Reconcile(ctx, reconcile.Request{ + _, err := reconciler.Reconcile(ctx, reconcile.Request{ NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, }) Expect(err).NotTo(HaveOccurred()) @@ -181,26 +155,14 @@ var _ = Describe("Signature Verification", func() { deploymentName = "sig-unsigned-agent" agentCardName = "sig-unsigned-card" namespace = "default" - secretName = "sig-unsigned-keys" ) ctx := context.Background() - BeforeEach(func() { - By("creating a public key Secret") - _, pubPEM := generateTestRSAKeyPair() - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: namespace}, - Data: map[string][]byte{"key": pubPEM}, - } - Expect(k8sClient.Create(ctx, secret)).To(Succeed()) - }) - AfterEach(func() { cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) cleanupResource(ctx, &appsv1.Deployment{}, deploymentName, namespace) cleanupResource(ctx, &corev1.Service{}, deploymentName, namespace) - cleanupResource(ctx, &corev1.Secret{}, secretName, namespace) }) It("should set validSignature=false for an unsigned card", func() { @@ -228,13 +190,7 @@ var _ = Describe("Signature Verification", func() { // No Signatures field } - provider, err := signature.NewSecretProvider(&signature.Config{ - Type: signature.ProviderTypeSecret, - SecretName: secretName, - SecretNamespace: namespace, - }) - Expect(err).NotTo(HaveOccurred()) - provider.(*signature.SecretProvider).SetClient(k8sClient) + provider := &mockSignatureProvider{} reconciler := &AgentCardReconciler{ Client: k8sClient, @@ -246,7 +202,7 @@ var _ = Describe("Signature Verification", func() { } // Reconcile twice (finalizer + verify) - _, err = reconciler.Reconcile(ctx, reconcile.Request{ + _, err := reconciler.Reconcile(ctx, reconcile.Request{ NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, }) Expect(err).NotTo(HaveOccurred()) @@ -279,7 +235,6 @@ var _ = Describe("Signature Verification", func() { deploymentName = "sig-wrongkey-agent" agentCardName = "sig-wrongkey-card" namespace = "default" - secretName = "sig-wrongkey-keys" ) ctx := context.Background() @@ -288,7 +243,6 @@ var _ = Describe("Signature Verification", func() { cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) cleanupResource(ctx, &appsv1.Deployment{}, deploymentName, namespace) cleanupResource(ctx, &corev1.Service{}, deploymentName, namespace) - cleanupResource(ctx, &corev1.Secret{}, secretName, namespace) }) It("should set validSignature=false when card is signed with wrong key", func() { @@ -296,13 +250,6 @@ var _ = Describe("Signature Verification", func() { signingKey, _ := generateTestRSAKeyPair() _, wrongPubPEM := generateTestRSAKeyPair() - By("creating secret with the wrong public key") - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: namespace}, - Data: map[string][]byte{"key-1": wrongPubPEM}, - } - Expect(k8sClient.Create(ctx, secret)).To(Succeed()) - By("creating Deployment and Service") createDeploymentWithService(ctx, deploymentName, namespace) @@ -327,14 +274,8 @@ var _ = Describe("Signature Verification", func() { } Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) - By("reconciling with signature verification") - provider, err := signature.NewSecretProvider(&signature.Config{ - Type: signature.ProviderTypeSecret, - SecretName: secretName, - SecretNamespace: namespace, - }) - Expect(err).NotTo(HaveOccurred()) - provider.(*signature.SecretProvider).SetClient(k8sClient) + By("reconciling with signature verification (wrong key)") + provider := &mockSignatureProvider{pubKeyPEM: wrongPubPEM} reconciler := &AgentCardReconciler{ Client: k8sClient, @@ -345,7 +286,7 @@ var _ = Describe("Signature Verification", func() { SignatureAuditMode: false, } - _, err = reconciler.Reconcile(ctx, reconcile.Request{ + _, err := reconciler.Reconcile(ctx, reconcile.Request{ NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, }) Expect(err).NotTo(HaveOccurred()) @@ -373,7 +314,6 @@ var _ = Describe("Signature Verification", func() { deploymentName = "sig-audit-agent" agentCardName = "sig-audit-card" namespace = "default" - secretName = "sig-audit-keys" ) ctx := context.Background() @@ -382,19 +322,11 @@ var _ = Describe("Signature Verification", func() { cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) cleanupResource(ctx, &appsv1.Deployment{}, deploymentName, namespace) cleanupResource(ctx, &corev1.Service{}, deploymentName, namespace) - cleanupResource(ctx, &corev1.Secret{}, secretName, namespace) }) It("should allow unsigned card in audit mode and set Synced=True", func() { _, pubPEM := generateTestRSAKeyPair() - By("creating secret") - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: namespace}, - Data: map[string][]byte{"key": pubPEM}, - } - Expect(k8sClient.Create(ctx, secret)).To(Succeed()) - By("creating Deployment and Service") createDeploymentWithService(ctx, deploymentName, namespace) @@ -419,14 +351,7 @@ var _ = Describe("Signature Verification", func() { Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) By("reconciling with audit mode enabled") - provider, err := signature.NewSecretProvider(&signature.Config{ - Type: signature.ProviderTypeSecret, - SecretName: secretName, - SecretNamespace: namespace, - AuditMode: true, - }) - Expect(err).NotTo(HaveOccurred()) - provider.(*signature.SecretProvider).SetClient(k8sClient) + provider := &mockSignatureProvider{pubKeyPEM: pubPEM} reconciler := &AgentCardReconciler{ Client: k8sClient, @@ -437,7 +362,7 @@ var _ = Describe("Signature Verification", func() { SignatureAuditMode: true, } - _, err = reconciler.Reconcile(ctx, reconcile.Request{ + _, err := reconciler.Reconcile(ctx, reconcile.Request{ NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, }) Expect(err).NotTo(HaveOccurred()) @@ -456,13 +381,13 @@ var _ = Describe("Signature Verification", func() { return syncedCond != nil && syncedCond.Status == metav1.ConditionTrue }, timeout, interval).Should(BeTrue()) - By("verifying SignatureVerified condition mentions audit mode") + By("verifying SignatureVerified condition is False with audit reason") card := &agentv1alpha1.AgentCard{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: agentCardName, Namespace: namespace}, card)).To(Succeed()) sigCond := findCondition(card.Status.Conditions, "SignatureVerified") Expect(sigCond).NotTo(BeNil()) - // In audit mode, unsigned cards pass via the provider (audit mode returns verified=true) - Expect(sigCond.Status).To(Equal(metav1.ConditionTrue)) + Expect(sigCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(sigCond.Reason).To(Equal(ReasonSignatureInvalidAudit)) }) }) @@ -537,7 +462,6 @@ var _ = Describe("Signature Verification", func() { deploymentName = "sig-identity-agent" agentCardName = "sig-identity-card" namespace = "default" - secretName = "sig-identity-keys" trustDomain = "test.local" ) @@ -547,32 +471,23 @@ var _ = Describe("Signature Verification", func() { cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) cleanupResource(ctx, &appsv1.Deployment{}, deploymentName, namespace) cleanupResource(ctx, &corev1.Service{}, deploymentName, namespace) - cleanupResource(ctx, &corev1.Secret{}, secretName, namespace) }) It("should set signatureIdentityMatch=true when both signature and binding pass", func() { By("generating key pair") privKey, pubPEM := generateTestRSAKeyPair() - By("creating secret") - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: namespace}, - Data: map[string][]byte{"key-1": pubPEM}, - } - Expect(k8sClient.Create(ctx, secret)).To(Succeed()) - By("creating Deployment and Service") createDeploymentWithService(ctx, deploymentName, namespace) - By("creating signed card data with SPIFFE ID in JWS protected header") + By("creating signed card data") expectedSpiffeID := "spiffe://" + trustDomain + "/ns/" + namespace + "/sa/test-sa" cardData := &agentv1alpha1.AgentCardData{ Name: "Identity Agent", Version: "1.0.0", URL: "http://localhost:8000", } - // Sign with spiffeID embedded in the JWS protected header - jwsSig := buildTestJWS(cardData, privKey, "key-1", expectedSpiffeID) + jwsSig := buildTestJWS(cardData, privKey, "key-1", "") cardData.Signatures = []agentv1alpha1.AgentCardSignature{jwsSig} By("creating AgentCard with both signature verification and identity binding") @@ -586,20 +501,14 @@ var _ = Describe("Signature Verification", func() { Name: deploymentName, }, IdentityBinding: &agentv1alpha1.IdentityBinding{ - AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{agentv1alpha1.SpiffeID(expectedSpiffeID)}, + TrustDomain: trustDomain, }, }, } Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) By("reconciling with both signature and identity binding") - provider, err := signature.NewSecretProvider(&signature.Config{ - Type: signature.ProviderTypeSecret, - SecretName: secretName, - SecretNamespace: namespace, - }) - Expect(err).NotTo(HaveOccurred()) - provider.(*signature.SecretProvider).SetClient(k8sClient) + provider := &mockSignatureProvider{pubKeyPEM: pubPEM, spiffeID: expectedSpiffeID} reconciler := &AgentCardReconciler{ Client: k8sClient, @@ -611,7 +520,7 @@ var _ = Describe("Signature Verification", func() { } // First reconcile adds the finalizer and returns early. - _, err = reconciler.Reconcile(ctx, reconcile.Request{ + _, err := reconciler.Reconcile(ctx, reconcile.Request{ NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, }) Expect(err).NotTo(HaveOccurred()) @@ -638,7 +547,6 @@ var _ = Describe("Signature Verification", func() { deploymentName = "sig-label-agent" agentCardName = "sig-label-card" namespace = "default" - secretName = "sig-label-keys" ) ctx := context.Background() @@ -647,17 +555,11 @@ var _ = Describe("Signature Verification", func() { cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) cleanupResource(ctx, &appsv1.Deployment{}, deploymentName, namespace) cleanupResource(ctx, &corev1.Service{}, deploymentName, namespace) - cleanupResource(ctx, &corev1.Secret{}, secretName, namespace) }) It("should propagate signature-verified=true label to Deployment pod template on valid signature", func() { - By("generating key pair and creating secret") + By("generating key pair") privKey, pubPEM := generateTestRSAKeyPair() - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: namespace}, - Data: map[string][]byte{"key-1": pubPEM}, - } - Expect(k8sClient.Create(ctx, secret)).To(Succeed()) By("creating a Deployment directly (not via Agent CRD)") replicas := int32(1) @@ -741,13 +643,7 @@ var _ = Describe("Signature Verification", func() { Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) By("reconciling with signature verification enabled") - provider, err := signature.NewSecretProvider(&signature.Config{ - Type: signature.ProviderTypeSecret, - SecretName: secretName, - SecretNamespace: namespace, - }) - Expect(err).NotTo(HaveOccurred()) - provider.(*signature.SecretProvider).SetClient(k8sClient) + provider := &mockSignatureProvider{pubKeyPEM: pubPEM} reconciler := &AgentCardReconciler{ Client: k8sClient, @@ -759,7 +655,7 @@ var _ = Describe("Signature Verification", func() { } // First reconcile adds finalizer - _, err = reconciler.Reconcile(ctx, reconcile.Request{ + _, err := reconciler.Reconcile(ctx, reconcile.Request{ NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, }) Expect(err).NotTo(HaveOccurred()) @@ -778,6 +674,101 @@ var _ = Describe("Signature Verification", func() { } return d.Spec.Template.Labels[LabelSignatureVerified] }, timeout, interval).Should(Equal("true")) + + By("verifying the per-card annotation is set on the Deployment") + d := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: namespace}, d)).To(Succeed()) + Expect(d.Annotations[AnnotationVerifiedStatePrefix+agentCardName]).To(Equal("true")) + }) + }) + + Context("Label Propagation — Repeated reconciles are idempotent (no loop)", func() { + const ( + deploymentName = "sig-label-idem-agent" + agentCardName = "sig-label-idem-card" + namespace = "default" + ) + + ctx := context.Background() + + AfterEach(func() { + cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) + cleanupResource(ctx, &appsv1.Deployment{}, deploymentName, namespace) + cleanupResource(ctx, &corev1.Service{}, deploymentName, namespace) + }) + + It("should not update the Deployment on a second reconcile when label is already correct", func() { + By("generating key pair") + privKey, pubPEM := generateTestRSAKeyPair() + + By("creating Deployment and Service") + createDeploymentWithService(ctx, deploymentName, namespace) + + By("creating signed card data") + makeCardData := func() *agentv1alpha1.AgentCardData { + cd := &agentv1alpha1.AgentCardData{ + Name: "Idempotent Label Agent", + Version: "1.0.0", + URL: "http://localhost:8000", + } + jwsSig := buildTestJWS(cd, privKey, "key-1", "") + cd.Signatures = []agentv1alpha1.AgentCardSignature{jwsSig} + return cd + } + + By("creating AgentCard with targetRef") + agentCard := &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Name: agentCardName, Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{ + SyncPeriod: "30s", + TargetRef: &agentv1alpha1.TargetRef{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: deploymentName, + }, + }, + } + Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) + + provider := &mockSignatureProvider{pubKeyPEM: pubPEM} + reconciler := &AgentCardReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AgentFetcher: &mockFetcherFunc{fn: func() *agentv1alpha1.AgentCardData { return makeCardData() }}, + RequireSignature: true, + SignatureProvider: provider, + SignatureAuditMode: false, + } + + By("first reconcile: adds finalizer") + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("second reconcile: sets label + annotation") + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("capturing the Deployment resourceVersion after label propagation") + d := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: namespace}, d)).To(Succeed()) + Expect(d.Spec.Template.Labels[LabelSignatureVerified]).To(Equal("true")) + Expect(d.Annotations[AnnotationVerifiedStatePrefix+agentCardName]).To(Equal("true")) + rvAfterFirstPropagation := d.ResourceVersion + + By("third reconcile: should be a no-op for the Deployment") + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("verifying the Deployment was NOT updated (resourceVersion unchanged)") + d2 := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: namespace}, d2)).To(Succeed()) + Expect(d2.ResourceVersion).To(Equal(rvAfterFirstPropagation)) }) }) @@ -786,7 +777,6 @@ var _ = Describe("Signature Verification", func() { deploymentName = "sig-label-rm-agent" agentCardName = "sig-label-rm-card" namespace = "default" - secretName = "sig-label-rm-keys" ) ctx := context.Background() @@ -795,7 +785,6 @@ var _ = Describe("Signature Verification", func() { cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) cleanupResource(ctx, &appsv1.Deployment{}, deploymentName, namespace) cleanupResource(ctx, &corev1.Service{}, deploymentName, namespace) - cleanupResource(ctx, &corev1.Secret{}, secretName, namespace) }) It("should remove signature-verified label when signature becomes invalid", func() { @@ -803,13 +792,6 @@ var _ = Describe("Signature Verification", func() { signingKey, _ := generateTestRSAKeyPair() _, wrongPubPEM := generateTestRSAKeyPair() - By("creating secret with the wrong public key") - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: namespace}, - Data: map[string][]byte{"key-1": wrongPubPEM}, - } - Expect(k8sClient.Create(ctx, secret)).To(Succeed()) - By("creating a Deployment with the signature-verified label already set (simulating previous valid state)") replicas := int32(1) deployment := &appsv1.Deployment{ @@ -899,14 +881,8 @@ var _ = Describe("Signature Verification", func() { } Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) - By("reconciling with signature verification enabled") - provider, err := signature.NewSecretProvider(&signature.Config{ - Type: signature.ProviderTypeSecret, - SecretName: secretName, - SecretNamespace: namespace, - }) - Expect(err).NotTo(HaveOccurred()) - provider.(*signature.SecretProvider).SetClient(k8sClient) + By("reconciling with signature verification enabled (wrong key)") + provider := &mockSignatureProvider{pubKeyPEM: wrongPubPEM} reconciler := &AgentCardReconciler{ Client: k8sClient, @@ -918,7 +894,7 @@ var _ = Describe("Signature Verification", func() { } // First reconcile adds finalizer - _, err = reconciler.Reconcile(ctx, reconcile.Request{ + _, err := reconciler.Reconcile(ctx, reconcile.Request{ NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, }) Expect(err).NotTo(HaveOccurred()) @@ -938,6 +914,11 @@ var _ = Describe("Signature Verification", func() { return d.Spec.Template.Labels[LabelSignatureVerified] }, timeout, interval).Should(BeEmpty()) + By("verifying the per-card annotation records false") + d2 := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: namespace}, d2)).To(Succeed()) + Expect(d2.Annotations[AnnotationVerifiedStatePrefix+agentCardName]).To(Equal("false")) + By("verifying validSignature=false") card := &agentv1alpha1.AgentCard{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: agentCardName, Namespace: namespace}, card)).To(Succeed()) @@ -945,6 +926,137 @@ var _ = Describe("Signature Verification", func() { Expect(*card.Status.ValidSignature).To(BeFalse()) }) }) + + Context("Label Propagation — Multi-card AND aggregation", func() { + const ( + deploymentName = "sig-multi-agent" + cardNameA = "sig-multi-card-a" + cardNameB = "sig-multi-card-b" + namespace = "default" + ) + + ctx := context.Background() + + AfterEach(func() { + cleanupResource(ctx, &agentv1alpha1.AgentCard{}, cardNameA, namespace) + cleanupResource(ctx, &agentv1alpha1.AgentCard{}, cardNameB, namespace) + cleanupResource(ctx, &appsv1.Deployment{}, deploymentName, namespace) + cleanupResource(ctx, &corev1.Service{}, deploymentName, namespace) + }) + + It("should set label=false when one card says false even if the other says true", func() { + By("generating key pair") + privKey, pubPEM := generateTestRSAKeyPair() + + By("creating Deployment and Service") + createDeploymentWithService(ctx, deploymentName, namespace) + + makeSignedCard := func() *agentv1alpha1.AgentCardData { + cd := &agentv1alpha1.AgentCardData{ + Name: "Multi Card Agent", Version: "1.0.0", URL: "http://localhost:8000", + } + jwsSig := buildTestJWS(cd, privKey, "key-1", "") + cd.Signatures = []agentv1alpha1.AgentCardSignature{jwsSig} + return cd + } + + makeUnsignedCard := func() *agentv1alpha1.AgentCardData { + return &agentv1alpha1.AgentCardData{ + Name: "Multi Card Agent", Version: "1.0.0", URL: "http://localhost:8000", + } + } + + By("creating two AgentCards targeting the same Deployment") + cardA := &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Name: cardNameA, Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{ + SyncPeriod: "30s", + TargetRef: &agentv1alpha1.TargetRef{APIVersion: "apps/v1", Kind: "Deployment", Name: deploymentName}, + }, + } + Expect(k8sClient.Create(ctx, cardA)).To(Succeed()) + + cardB := &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Name: cardNameB, Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{ + SyncPeriod: "30s", + TargetRef: &agentv1alpha1.TargetRef{APIVersion: "apps/v1", Kind: "Deployment", Name: deploymentName}, + }, + } + Expect(k8sClient.Create(ctx, cardB)).To(Succeed()) + + By("reconciling card A with valid signature (verified=true)") + providerA := &mockSignatureProvider{pubKeyPEM: pubPEM} + reconcilerA := &AgentCardReconciler{ + Client: k8sClient, Scheme: k8sClient.Scheme(), + AgentFetcher: &mockFetcherFunc{fn: makeSignedCard}, + RequireSignature: true, + SignatureProvider: providerA, + SignatureAuditMode: false, + } + _, err := reconcilerA.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: cardNameA, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + _, err = reconcilerA.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: cardNameA, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("verifying label is true after card A (only card so far)") + d := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: namespace}, d)).To(Succeed()) + Expect(d.Spec.Template.Labels[LabelSignatureVerified]).To(Equal("true")) + Expect(d.Annotations[AnnotationVerifiedStatePrefix+cardNameA]).To(Equal("true")) + + By("reconciling card B with unsigned card (verified=false)") + reconcilerB := &AgentCardReconciler{ + Client: k8sClient, Scheme: k8sClient.Scheme(), + AgentFetcher: &mockFetcherFunc{fn: makeUnsignedCard}, + RequireSignature: true, + SignatureProvider: providerA, + SignatureAuditMode: false, + } + _, err = reconcilerB.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: cardNameB, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + _, err = reconcilerB.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: cardNameB, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("verifying label is now empty (card B says false, AND aggregation)") + Eventually(func() string { + d := &appsv1.Deployment{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: namespace}, d); err != nil { + return "error" + } + return d.Spec.Template.Labels[LabelSignatureVerified] + }, timeout, interval).Should(BeEmpty()) + + By("verifying per-card annotations are correct") + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: namespace}, d)).To(Succeed()) + Expect(d.Annotations[AnnotationVerifiedStatePrefix+cardNameA]).To(Equal("true")) + Expect(d.Annotations[AnnotationVerifiedStatePrefix+cardNameB]).To(Equal("false")) + + By("reconciling card B again with valid signature (both cards now true)") + reconcilerB.AgentFetcher = &mockFetcherFunc{fn: makeSignedCard} + _, err = reconcilerB.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: cardNameB, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("verifying label is now true (both cards agree)") + Eventually(func() string { + d := &appsv1.Deployment{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: namespace}, d); err != nil { + return "" + } + return d.Spec.Template.Labels[LabelSignatureVerified] + }, timeout, interval).Should(Equal("true")) + }) + }) }) // --- Test helpers --- @@ -958,25 +1070,16 @@ func generateTestRSAKeyPair() (*rsa.PrivateKey, []byte) { } // buildTestJWS creates a JWS signature for integration testing. -// Builds a protected header with alg, kid, and optional spiffe_id, -// then signs the canonical card payload per A2A spec JWS format. -func buildTestJWS(cardData *agentv1alpha1.AgentCardData, privKey *rsa.PrivateKey, kid, spiffeID string) agentv1alpha1.AgentCardSignature { - // Build protected header (per A2A spec §8.4.2: alg, typ, kid are MUST) +func buildTestJWS(cardData *agentv1alpha1.AgentCardData, privKey *rsa.PrivateKey, kid, _ string) agentv1alpha1.AgentCardSignature { header := map[string]string{"alg": "RS256", "typ": "JOSE", "kid": kid} - if spiffeID != "" { - header["spiffe_id"] = spiffeID - } headerJSON, _ := json.Marshal(header) protectedB64 := base64.RawURLEncoding.EncodeToString(headerJSON) - // Use the production canonical JSON to guarantee test/prod parity payload, _ := signature.CreateCanonicalCardJSON(cardData) - // Construct signing input payloadB64 := base64.RawURLEncoding.EncodeToString(payload) signingInput := []byte(protectedB64 + "." + payloadB64) - // Sign hash := sha256.Sum256(signingInput) sig, _ := rsa.SignPKCS1v15(rand.Reader, privKey, crypto.SHA256, hash[:]) @@ -986,6 +1089,38 @@ func buildTestJWS(cardData *agentv1alpha1.AgentCardData, privKey *rsa.PrivateKey } } +// mockFetcherFunc returns a fresh AgentCardData on each call, avoiding the +// mutation issue where the reconciler overwrites cardData.URL in place. +type mockFetcherFunc struct { + fn func() *agentv1alpha1.AgentCardData +} + +func (m *mockFetcherFunc) Fetch(_ context.Context, _, _ string) (*agentv1alpha1.AgentCardData, error) { + return m.fn(), nil +} + +// mockSignatureProvider wraps VerifyJWS with a fixed public key for tests. +// This replaces the deleted SecretProvider in test code. +type mockSignatureProvider struct { + pubKeyPEM []byte + spiffeID string // returned in result when verification succeeds +} + +func (m *mockSignatureProvider) Name() string { return "mock" } +func (m *mockSignatureProvider) BundleHash() string { return "mock-hash" } + +func (m *mockSignatureProvider) VerifySignature(ctx context.Context, cardData *agentv1alpha1.AgentCardData, + signatures []agentv1alpha1.AgentCardSignature) (*signature.VerificationResult, error) { + for i := range signatures { + result, err := signature.VerifyJWS(cardData, &signatures[i], m.pubKeyPEM) + if err == nil && result != nil && result.Verified { + result.SpiffeID = m.spiffeID + return result, nil + } + } + return &signature.VerificationResult{Verified: false, Details: "no valid signature"}, nil +} + // createDeploymentWithService creates a Deployment (with Available status) and a Service for testing. func createDeploymentWithService(ctx context.Context, name, namespace string) { labels := map[string]string{ diff --git a/kagenti-operator/internal/signature/jwks.go b/kagenti-operator/internal/signature/jwks.go deleted file mode 100644 index 8a4da2aa..00000000 --- a/kagenti-operator/internal/signature/jwks.go +++ /dev/null @@ -1,422 +0,0 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package signature - -import ( - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rsa" - "crypto/x509" - "encoding/base64" - "encoding/json" - "encoding/pem" - "fmt" - "io" - "math/big" - "net/http" - "strings" - "sync" - "time" - - agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" - "golang.org/x/sync/singleflight" - ctrl "sigs.k8s.io/controller-runtime" -) - -var ( - jwksLogger = ctrl.Log.WithName("signature").WithName("jwks") -) - -// JWKSProvider verifies JWS signatures using a JWKS (JSON Web Key Set) endpoint -type JWKSProvider struct { - jwksURL string - httpClient *http.Client - auditMode bool - - // Cache for JWKS keys - keysMutex sync.RWMutex - keysCache map[string]*JWK - lastFetch time.Time - cacheTTL time.Duration - - // singleflight prevents concurrent refreshes (thundering herd on cache expiry) - refreshGroup singleflight.Group - - // Rate-limit forced refreshes (cache-miss → re-fetch) to prevent a burst of - // unknown kid values from hammering the JWKS endpoint. - lastForcedRefresh time.Time -} - -// JWK represents a JSON Web Key -type JWK struct { - Kid string `json:"kid"` // Key ID - Kty string `json:"kty"` // Key Type (RSA, EC, etc.) - Use string `json:"use"` // Use (sig, enc) - Alg string `json:"alg"` // Algorithm - N string `json:"n"` // RSA modulus - E string `json:"e"` // RSA exponent - X string `json:"x"` // EC x coordinate - Y string `json:"y"` // EC y coordinate - Crv string `json:"crv"` // EC curve -} - -// JWKS represents a JSON Web Key Set -type JWKS struct { - Keys []JWK `json:"keys"` -} - -// DefaultJWKSCacheTTL is the default cache duration for JWKS keys -const DefaultJWKSCacheTTL = 5 * time.Minute - -// maxJWKSResponseSize caps the maximum size of a JWKS response to prevent -// a malicious endpoint from causing an OOM with an unbounded body. -const maxJWKSResponseSize = 1 << 20 // 1 MiB - -// forcedRefreshCooldown is the minimum interval between forced refreshes -// triggered by cache misses on unknown kid values. This prevents a burst -// of requests with bogus kid values from hammering the JWKS endpoint. -const forcedRefreshCooldown = 30 * time.Second - -// NewJWKSProvider creates a new JWKS-based signature verification provider -func NewJWKSProvider(config *Config) (Provider, error) { - if config.JWKSURL == "" { - return nil, fmt.Errorf("JWKS URL is required") - } - - cacheTTL := config.JWKSCacheTTL - if cacheTTL <= 0 { - cacheTTL = DefaultJWKSCacheTTL - } - - if !strings.HasPrefix(config.JWKSURL, "https://") { - jwksLogger.V(0).Info("SECURITY WARNING: JWKS URL does not use HTTPS — keys may be intercepted in transit. "+ - "Use an HTTPS endpoint in production.", - "url", config.JWKSURL) - } - - return &JWKSProvider{ - jwksURL: config.JWKSURL, - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, - auditMode: config.AuditMode, - keysCache: make(map[string]*JWK), - cacheTTL: cacheTTL, - }, nil -} - -// VerifySignature verifies JWS signatures using keys from a JWKS endpoint. -// Iterates over the signatures array; returns success on the first verified signature. -func (p *JWKSProvider) VerifySignature(ctx context.Context, cardData *agentv1alpha1.AgentCardData, signatures []agentv1alpha1.AgentCardSignature) (*VerificationResult, error) { - jwksLogger.Info("Verifying JWS signature using JWKS", "url", p.jwksURL) - - if len(signatures) == 0 { - result := &VerificationResult{ - Verified: false, - Details: "AgentCard does not contain any signatures", - } - if p.auditMode { - jwksLogger.Info("Audit mode: AgentCard has no signatures, allowing anyway", "card", cardData.Name) - result.Verified = true - result.Details = "AgentCard has no signatures (audit mode: allowed)" - return result, nil - } - return result, nil - } - - // Fetch JWKS keys - if err := p.refreshKeysIfNeeded(ctx); err != nil { - if p.auditMode { - jwksLogger.Error(err, "Audit mode: Failed to fetch JWKS, allowing anyway") - return &VerificationResult{ - Verified: true, - Details: fmt.Sprintf("Failed to fetch JWKS (audit mode: allowed): %v", err), - }, nil - } - return &VerificationResult{ - Verified: false, - Details: fmt.Sprintf("Failed to fetch JWKS: %v", err), - }, err - } - - // Try each signature in the array - for i := range signatures { - sig := &signatures[i] - - // Decode protected header to get kid - header, headerErr := DecodeProtectedHeader(sig.Protected) - if headerErr != nil { - jwksLogger.Info("Skipping signature with invalid protected header", - "index", i, "error", headerErr) - continue - } - - kid := header.KeyID - - // Find the key with matching kid - jwk := p.findKey(kid) - if jwk == nil { - // Force refresh in case of key rotation, but rate-limit to prevent - // a burst of unknown kid values from hammering the JWKS endpoint. - p.keysMutex.RLock() - canRefresh := time.Since(p.lastForcedRefresh) > forcedRefreshCooldown - p.keysMutex.RUnlock() - - if canRefresh { - jwksLogger.Info("Key not found in cache, forcing JWKS refresh", "keyID", kid) - if refreshErr := p.fetchKeys(ctx); refreshErr != nil { - jwksLogger.Error(refreshErr, "Failed to refresh JWKS after cache miss") - } else { - p.keysMutex.Lock() - p.lastForcedRefresh = time.Now() - p.keysMutex.Unlock() - jwk = p.findKey(kid) - } - } else { - jwksLogger.Info("Key not found in cache; forced refresh on cooldown", - "keyID", kid, "cooldown", forcedRefreshCooldown) - } - } - - if jwk == nil { - jwksLogger.Info("Key not found in JWKS after refresh", "keyID", kid) - continue - } - - // Convert JWK to PEM - publicKeyPEM, err := p.jwkToPublicKeyPEM(jwk) - if err != nil { - jwksLogger.Error(err, "Failed to convert JWK to PEM", "keyID", kid) - continue - } - - // Verify the signature - result, verifyErr := VerifyJWS(cardData, sig, publicKeyPEM) - if verifyErr == nil && result != nil && result.Verified { - return result, nil - } - } - - // No signature verified - err := fmt.Errorf("JWS signature verification failed with all JWKS keys") - if p.auditMode { - jwksLogger.Error(err, "Audit mode: Verification failed, allowing anyway") - return &VerificationResult{ - Verified: true, - Details: fmt.Sprintf("Signature verification failed (audit mode: allowed): %v", err), - }, nil - } - return &VerificationResult{ - Verified: false, - Details: err.Error(), - }, err -} - -// refreshKeysIfNeeded fetches JWKS keys if cache is stale. -// Uses singleflight to prevent thundering herd when multiple goroutines -// see the cache as expired simultaneously. -// -// Note on context cancellation: singleflight.Do executes the function once -// and shares the result across all callers. The context passed here belongs -// to the first caller; if that caller's context is cancelled, the HTTP request -// inside fetchKeys will be aborted and all waiters receive the error. This is -// acceptable because callers will retry on the next reconcile cycle. If this -// becomes an issue, consider using context.WithoutCancel (Go 1.21+) to -// detach the fetch from any single caller's lifetime. -func (p *JWKSProvider) refreshKeysIfNeeded(ctx context.Context) error { - p.keysMutex.RLock() - needsRefresh := time.Since(p.lastFetch) > p.cacheTTL - p.keysMutex.RUnlock() - - if !needsRefresh { - return nil - } - - _, err, _ := p.refreshGroup.Do("jwks-refresh", func() (interface{}, error) { - return nil, p.fetchKeys(ctx) - }) - return err -} - -// fetchKeys fetches keys from JWKS endpoint -func (p *JWKSProvider) fetchKeys(ctx context.Context) error { - jwksLogger.Info("Fetching JWKS keys", "url", p.jwksURL) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.jwksURL, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - resp, err := p.httpClient.Do(req) - if err != nil { - return fmt.Errorf("failed to fetch JWKS: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, maxJWKSResponseSize)) - return fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) - } - - body, err := io.ReadAll(io.LimitReader(resp.Body, maxJWKSResponseSize)) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - var jwks JWKS - if err := json.Unmarshal(body, &jwks); err != nil { - return fmt.Errorf("failed to parse JWKS: %w", err) - } - - // Update cache - p.keysMutex.Lock() - defer p.keysMutex.Unlock() - - p.keysCache = make(map[string]*JWK) - for i := range jwks.Keys { - jwk := &jwks.Keys[i] - if jwk.Kid != "" { - p.keysCache[jwk.Kid] = jwk - } - } - p.lastFetch = time.Now() - - jwksLogger.Info("Successfully fetched JWKS keys", "count", len(p.keysCache)) - return nil -} - -// findKey finds a key by kid -func (p *JWKSProvider) findKey(kid string) *JWK { - p.keysMutex.RLock() - defer p.keysMutex.RUnlock() - return p.keysCache[kid] -} - -// jwkToPublicKeyPEM converts a JWK to PEM format -func (p *JWKSProvider) jwkToPublicKeyPEM(jwk *JWK) ([]byte, error) { - switch jwk.Kty { - case "RSA": - return p.rsaJWKToPEM(jwk) - case "EC": - return p.ecJWKToPEM(jwk) - default: - return nil, fmt.Errorf("unsupported key type: %s", jwk.Kty) - } -} - -// rsaJWKToPEM converts an RSA JWK to PEM format. -// Validates that the modulus is at least 2048 bits and the exponent is -// representable as a Go int to prevent insecure keys and overflows. -func (p *JWKSProvider) rsaJWKToPEM(jwk *JWK) ([]byte, error) { - nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N) - if err != nil { - return nil, fmt.Errorf("failed to decode modulus: %w", err) - } - - // Reject RSA keys smaller than 2048 bits — anything less is cryptographically insecure. - if len(nBytes) < 256 { // 2048 bits = 256 bytes - return nil, fmt.Errorf("RSA modulus too small: %d bits (minimum 2048)", len(nBytes)*8) - } - - eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E) - if err != nil { - return nil, fmt.Errorf("failed to decode exponent: %w", err) - } - - // Guard against exponent overflow: standard RSA exponents (e.g. 65537) - // fit in 3 bytes; anything beyond 8 bytes is malformed. - if len(eBytes) > 8 { - return nil, fmt.Errorf("RSA exponent too large: %d bytes (maximum 8)", len(eBytes)) - } - - var eInt int - for _, b := range eBytes { - eInt = eInt<<8 + int(b) - } - - publicKey := &rsa.PublicKey{ - N: new(big.Int).SetBytes(nBytes), - E: eInt, - } - - return marshalPublicKeyToPEM(publicKey) -} - -// ecJWKToPEM converts an EC JWK to PEM format. -// Validates that the decoded (X, Y) point lies on the specified curve to -// prevent invalid-curve attacks where a malicious JWKS endpoint serves -// off-curve points that could cause ecdsa.Verify to behave unpredictably. -func (p *JWKSProvider) ecJWKToPEM(jwk *JWK) ([]byte, error) { - var curve elliptic.Curve - switch jwk.Crv { - case "P-256": - curve = elliptic.P256() - case "P-384": - curve = elliptic.P384() - case "P-521": - curve = elliptic.P521() - default: - return nil, fmt.Errorf("unsupported EC curve: %s", jwk.Crv) - } - - xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X) - if err != nil { - return nil, fmt.Errorf("failed to decode EC x coordinate: %w", err) - } - - yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y) - if err != nil { - return nil, fmt.Errorf("failed to decode EC y coordinate: %w", err) - } - - x := new(big.Int).SetBytes(xBytes) - y := new(big.Int).SetBytes(yBytes) - - // Reject points not on the curve to prevent invalid-curve attacks. - if !curve.IsOnCurve(x, y) { - return nil, fmt.Errorf("EC point (X, Y) is not on curve %s — possible invalid-curve attack", jwk.Crv) - } - - publicKey := &ecdsa.PublicKey{ - Curve: curve, - X: x, - Y: y, - } - - return marshalPublicKeyToPEM(publicKey) -} - -// marshalPublicKeyToPEM marshals any public key to PKIX PEM format -func marshalPublicKeyToPEM(publicKey interface{}) ([]byte, error) { - pkixBytes, err := x509.MarshalPKIXPublicKey(publicKey) - if err != nil { - return nil, fmt.Errorf("failed to marshal public key: %w", err) - } - - pemBlock := &pem.Block{ - Type: "PUBLIC KEY", - Bytes: pkixBytes, - } - - return pem.EncodeToMemory(pemBlock), nil -} - -// Name returns the provider name -func (p *JWKSProvider) Name() string { - return "jwks" -} diff --git a/kagenti-operator/internal/signature/metrics.go b/kagenti-operator/internal/signature/metrics.go index 6b00a1b1..07747637 100644 --- a/kagenti-operator/internal/signature/metrics.go +++ b/kagenti-operator/internal/signature/metrics.go @@ -22,40 +22,33 @@ import ( ) var ( - // SignatureVerificationTotal tracks the total number of signature verifications SignatureVerificationTotal = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "a2a_signature_verification_total", - Help: "Total number of A2A signature verifications", + Help: "Total A2A signature verifications", }, []string{"provider", "result", "audit_mode"}, ) - // SignatureVerificationDuration tracks the duration of signature verifications SignatureVerificationDuration = prometheus.NewHistogramVec( prometheus.HistogramOpts{ Name: "a2a_signature_verification_duration_seconds", - Help: "Duration of A2A signature verifications in seconds", + Help: "Duration of A2A signature verifications", Buckets: prometheus.DefBuckets, }, []string{"provider"}, ) - // SignatureVerificationErrors tracks signature verification errors SignatureVerificationErrors = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "a2a_signature_verification_errors_total", - Help: "Total number of A2A signature verification errors", + Help: "Total A2A signature verification errors", }, []string{"provider", "error_type"}, ) ) func init() { - // Register custom metrics with the global Prometheus registry. - // Use Register (not MustRegister) and ignore AlreadyRegisteredError to prevent - // panics when the package is imported from multiple test suites. - // Non-AlreadyRegisteredError errors are re-panicked because they indicate a real problem. for _, c := range []prometheus.Collector{ SignatureVerificationTotal, SignatureVerificationDuration, @@ -63,13 +56,12 @@ func init() { } { if err := metrics.Registry.Register(c); err != nil { if _, ok := err.(prometheus.AlreadyRegisteredError); !ok { - panic(err) // Re-panic for unexpected registration errors + panic(err) } } } } -// RecordVerification records a signature verification result func RecordVerification(provider string, verified bool, auditMode bool) { result := "failed" if verified { @@ -82,7 +74,6 @@ func RecordVerification(provider string, verified bool, auditMode bool) { SignatureVerificationTotal.WithLabelValues(provider, result, audit).Inc() } -// RecordError records a signature verification error func RecordError(provider string, errorType string) { SignatureVerificationErrors.WithLabelValues(provider, errorType).Inc() } diff --git a/kagenti-operator/internal/signature/noop.go b/kagenti-operator/internal/signature/noop.go deleted file mode 100644 index 747a8d9d..00000000 --- a/kagenti-operator/internal/signature/noop.go +++ /dev/null @@ -1,45 +0,0 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package signature - -import ( - "context" - - agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" -) - -// NoOpProvider is a provider that always returns verified=true. -// Used when signature verification is disabled. -type NoOpProvider struct{} - -// NewNoOpProvider creates a new NoOp provider -func NewNoOpProvider() Provider { - return &NoOpProvider{} -} - -// VerifySignature always returns success -func (p *NoOpProvider) VerifySignature(ctx context.Context, cardData *agentv1alpha1.AgentCardData, signatures []agentv1alpha1.AgentCardSignature) (*VerificationResult, error) { - return &VerificationResult{ - Verified: true, - Details: "Signature verification disabled", - }, nil -} - -// Name returns the provider name -func (p *NoOpProvider) Name() string { - return "noop" -} diff --git a/kagenti-operator/internal/signature/provider.go b/kagenti-operator/internal/signature/provider.go index bec37124..b5c97f9d 100644 --- a/kagenti-operator/internal/signature/provider.go +++ b/kagenti-operator/internal/signature/provider.go @@ -22,76 +22,58 @@ import ( "time" agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" ) -// VerificationResult contains the result of signature verification. +// VerificationResult holds the outcome of a signature verification. // -// Contract: -// - The returned error from Provider.VerifySignature signals infrastructure failures -// (secret not found, network error, client not initialised). Callers should treat -// a non-nil error as a transient/retriable problem. -// - Crypto outcomes (wrong key, tampered payload, no signature) are conveyed via -// Verified=false + Details. The returned error is nil in these cases. -// - When Verified=true, Details carries a human-readable summary of the verification. +// Error contract: a non-nil error from Provider.VerifySignature indicates an +// infrastructure failure (retriable). Cryptographic failures set Verified=false +// with a nil error. type VerificationResult struct { - Verified bool - KeyID string - SpiffeID string // SPIFFE ID extracted from the JWS protected header - Details string + Verified bool + KeyID string + SpiffeID string // from leaf cert SAN URI + Details string + LeafNotAfter time.Time // leaf cert expiry } -// Provider defines the interface for A2A signature verification. -// Implementations can support Kubernetes Secrets, JWKS servers, or other methods. +// Provider verifies A2A AgentCard JWS signatures (spec section 8.4). type Provider interface { - // VerifySignature verifies AgentCard JWS signatures per A2A spec section 8.4. - // Accepts the card data (for canonical payload) and the JWS signatures array. - // Returns success if at least one signature verifies. + // VerifySignature returns success if at least one signature verifies. VerifySignature(ctx context.Context, cardData *agentv1alpha1.AgentCardData, signatures []agentv1alpha1.AgentCardSignature) (*VerificationResult, error) - - // Name returns the provider name for logging and metrics Name() string + // BundleHash returns a hash of the current trust bundle for change detection. + BundleHash() string } -// NewProvider creates a signature verification provider based on configuration -func NewProvider(config *Config) (Provider, error) { - if config == nil { - return nil, fmt.Errorf("provider config cannot be nil") - } - - switch config.Type { - case ProviderTypeSecret: - return NewSecretProvider(config) - case ProviderTypeJWKS: - return NewJWKSProvider(config) - case ProviderTypeNone: - return NewNoOpProvider(), nil - default: - return nil, fmt.Errorf("unknown provider type: %s", config.Type) - } -} - -// ProviderType defines the type of signature verification provider type ProviderType string const ( - ProviderTypeSecret ProviderType = "secret" - ProviderTypeJWKS ProviderType = "jwks" - ProviderTypeNone ProviderType = "none" + ProviderTypeX5C ProviderType = "x5c" ) -// Config holds configuration for signature verification providers +// Config holds configuration for the signature verification provider. type Config struct { Type ProviderType - // For secret-based provider - SecretName string - SecretNamespace string - SecretKey string + TrustBundleConfigMapName string // ConfigMap name (SPIFFE JSON format) + TrustBundleConfigMapNS string + TrustBundleConfigMapKey string // default: "bundle.spiffe" + TrustBundleRefreshInterval time.Duration // default: 5m + + Client client.Client +} - // For JWKS provider - JWKSURL string - JWKSCacheTTL time.Duration // How long to cache JWKS keys (default: 5 minutes) +func NewProvider(config *Config) (Provider, error) { + if config == nil { + return nil, fmt.Errorf("provider config cannot be nil") + } - // Common settings - AuditMode bool // If true, log verification failures but don't block + switch config.Type { + case ProviderTypeX5C: + return NewX5CProvider(config) + default: + return nil, fmt.Errorf("unknown provider type: %s (only 'x5c' is supported)", config.Type) + } } diff --git a/kagenti-operator/internal/signature/provider_test.go b/kagenti-operator/internal/signature/provider_test.go index 25933e2d..c21a9cae 100644 --- a/kagenti-operator/internal/signature/provider_test.go +++ b/kagenti-operator/internal/signature/provider_test.go @@ -17,15 +17,9 @@ limitations under the License. package signature import ( - "context" "testing" - "time" - - agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" ) -// --- NewProvider factory tests --- - func TestNewProvider_NilConfig(t *testing.T) { _, err := NewProvider(nil) if err == nil { @@ -40,140 +34,33 @@ func TestNewProvider_UnknownType(t *testing.T) { } } -func TestNewProvider_None(t *testing.T) { - p, err := NewProvider(&Config{Type: ProviderTypeNone}) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if p.Name() != "noop" { - t.Errorf("Expected name 'noop', got '%s'", p.Name()) - } -} - -func TestNewProvider_Secret_MissingName(t *testing.T) { +func TestNewProvider_X5C_MissingConfigMapName(t *testing.T) { _, err := NewProvider(&Config{ - Type: ProviderTypeSecret, - SecretNamespace: "default", + Type: ProviderTypeX5C, + TrustBundleConfigMapNS: "spire-system", }) if err == nil { - t.Error("Expected error when SecretName is empty") + t.Error("Expected error when TrustBundleConfigMapName is empty") } } -func TestNewProvider_Secret_MissingNamespace(t *testing.T) { +func TestNewProvider_X5C_MissingConfigMapNamespace(t *testing.T) { _, err := NewProvider(&Config{ - Type: ProviderTypeSecret, - SecretName: "my-secret", + Type: ProviderTypeX5C, + TrustBundleConfigMapName: "spire-bundle", }) if err == nil { - t.Error("Expected error when SecretNamespace is empty") + t.Error("Expected error when TrustBundleConfigMapNS is empty") } } -func TestNewProvider_Secret_Valid(t *testing.T) { - p, err := NewProvider(&Config{ - Type: ProviderTypeSecret, - SecretName: "a2a-keys", - SecretNamespace: "kagenti-system", +func TestNewProvider_X5C_MissingClient(t *testing.T) { + _, err := NewProvider(&Config{ + Type: ProviderTypeX5C, + TrustBundleConfigMapName: "spire-bundle", + TrustBundleConfigMapNS: "spire-system", }) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if p.Name() != "secret" { - t.Errorf("Expected name 'secret', got '%s'", p.Name()) - } -} - -func TestNewProvider_JWKS_MissingURL(t *testing.T) { - _, err := NewProvider(&Config{Type: ProviderTypeJWKS}) if err == nil { - t.Error("Expected error when JWKSURL is empty") - } -} - -func TestNewProvider_JWKS_Valid(t *testing.T) { - p, err := NewProvider(&Config{ - Type: ProviderTypeJWKS, - JWKSURL: "https://example.com/.well-known/jwks.json", - }) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if p.Name() != "jwks" { - t.Errorf("Expected name 'jwks', got '%s'", p.Name()) - } -} - -func TestNewProvider_JWKS_CustomCacheTTL(t *testing.T) { - p, err := NewProvider(&Config{ - Type: ProviderTypeJWKS, - JWKSURL: "https://example.com/.well-known/jwks.json", - JWKSCacheTTL: 10 * time.Minute, - }) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - jwksProvider, ok := p.(*JWKSProvider) - if !ok { - t.Fatal("Expected *JWKSProvider type") - } - if jwksProvider.cacheTTL != 10*time.Minute { - t.Errorf("Expected cacheTTL=10m, got %v", jwksProvider.cacheTTL) - } -} - -// --- NoOpProvider tests --- - -func TestNoOpProvider_AlwaysVerified(t *testing.T) { - p := NewNoOpProvider() - ctx := context.Background() - - // With nil signatures - result, err := p.VerifySignature(ctx, newCardData("Agent", "http://a:8000", "1.0"), nil) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if !result.Verified { - t.Error("NoOpProvider should always return verified=true") - } - - // With a JWS signature - result, err = p.VerifySignature(ctx, newCardData("Agent", "http://a:8000", "1.0"), - []agentv1alpha1.AgentCardSignature{ - {Protected: "eyJhbGciOiJSUzI1NiJ9", Signature: "fake-sig"}, - }) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if !result.Verified { - t.Error("NoOpProvider should always return verified=true, even with signatures") - } -} - -func TestNoOpProvider_Name(t *testing.T) { - p := NewNoOpProvider() - if p.Name() != "noop" { - t.Errorf("Expected 'noop', got '%s'", p.Name()) - } -} - -// --- Config validation tests --- - -func TestConfig_ProviderTypes(t *testing.T) { - tests := []struct { - name string - pt ProviderType - expected string - }{ - {"secret", ProviderTypeSecret, "secret"}, - {"jwks", ProviderTypeJWKS, "jwks"}, - {"none", ProviderTypeNone, "none"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if string(tt.pt) != tt.expected { - t.Errorf("Expected %s, got %s", tt.expected, tt.pt) - } - }) + t.Error("Expected error when Client is nil") } } diff --git a/kagenti-operator/internal/signature/secret.go b/kagenti-operator/internal/signature/secret.go deleted file mode 100644 index 8b7d1509..00000000 --- a/kagenti-operator/internal/signature/secret.go +++ /dev/null @@ -1,220 +0,0 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package signature - -import ( - "context" - "fmt" - - agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -var ( - secretLogger = ctrl.Log.WithName("signature").WithName("secret") -) - -// SecretProvider verifies JWS signatures using public keys stored in Kubernetes Secrets -type SecretProvider struct { - client client.Client - secretName string - secretNamespace string - secretKey string - auditMode bool -} - -// NewSecretProvider creates a new Secret-based signature verification provider -func NewSecretProvider(config *Config) (Provider, error) { - if config.SecretName == "" { - return nil, fmt.Errorf("secret name is required") - } - if config.SecretNamespace == "" { - return nil, fmt.Errorf("secret namespace is required") - } - - return &SecretProvider{ - secretName: config.SecretName, - secretNamespace: config.SecretNamespace, - secretKey: config.SecretKey, - auditMode: config.AuditMode, - }, nil -} - -// SetClient sets the Kubernetes client (called after provider creation) -func (p *SecretProvider) SetClient(c client.Client) { - p.client = c -} - -// VerifySignature verifies JWS signatures using public keys from a Kubernetes Secret. -// Iterates over the signatures array; returns success on the first verified signature. -// -// IMPORTANT: This method must never log secret key material. Only the secret name, -// namespace, and key names (not values) are logged. -func (p *SecretProvider) VerifySignature(ctx context.Context, cardData *agentv1alpha1.AgentCardData, signatures []agentv1alpha1.AgentCardSignature) (*VerificationResult, error) { - secretLogger.Info("Verifying JWS signature using Kubernetes Secret", - "secret", p.secretName, - "namespace", p.secretNamespace) - - if p.client == nil { - return &VerificationResult{ - Verified: false, - Details: "Internal error: client not set", - }, fmt.Errorf("kubernetes client not initialized") - } - - if len(signatures) == 0 { - result := &VerificationResult{ - Verified: false, - Details: "AgentCard does not contain any signatures", - } - if p.auditMode { - secretLogger.Info("Audit mode: AgentCard has no signatures, allowing anyway", "card", cardData.Name) - result.Verified = true - result.Details = "AgentCard has no signatures (audit mode: allowed)" - return result, nil - } - return result, nil - } - - // Fetch the secret containing public keys - secret := &corev1.Secret{} - err := p.client.Get(ctx, types.NamespacedName{ - Name: p.secretName, - Namespace: p.secretNamespace, - }, secret) - if err != nil { - if p.auditMode { - secretLogger.Error(err, "Audit mode: Failed to fetch secret, allowing anyway") - return &VerificationResult{ - Verified: true, - Details: fmt.Sprintf("Failed to fetch secret (audit mode: allowed): %v", err), - }, nil - } - return &VerificationResult{ - Verified: false, - Details: fmt.Sprintf("Failed to fetch secret: %v", err), - }, err - } - - // Try each signature in the array - for i := range signatures { - sig := &signatures[i] - - // Decode protected header to extract kid - header, headerErr := DecodeProtectedHeader(sig.Protected) - if headerErr != nil { - secretLogger.Info("Skipping signature with invalid protected header", - "index", i, "error", headerErr) - continue - } - - // Try to find the key by kid from the protected header - kid := header.KeyID - keyData, keyErr := p.getKeyFromSecret(secret, kid) - if keyErr == nil { - result, verifyErr := VerifyJWS(cardData, sig, keyData) - if verifyErr == nil && result != nil && result.Verified { - return result, nil - } - secretLogger.Info("Verification failed with matched key, trying fallback", - "keyID", kid, "error", verifyErr) - } - - // Fallback: try all keys in the secret. - // Warn if the secret contains many keys — brute-forcing is O(keys × signatures). - if len(secret.Data) > 10 { - secretLogger.Info("WARNING: Secret contains many keys, brute-force fallback may be slow", - "secret", p.secretName, "keyCount", len(secret.Data)) - } - for keyName, data := range secret.Data { - if keyErr == nil && string(data) == string(keyData) { - continue // skip the key we already tried - } - result, verifyErr := VerifyJWS(cardData, sig, data) - if verifyErr == nil && result != nil && result.Verified { - secretLogger.Info("Signature verified with fallback key", - "requestedKeyID", kid, - "matchedKey", keyName) - result.KeyID = keyName - return result, nil - } - } - } - - // No signature verified - err = fmt.Errorf("JWS signature verification failed with all available keys") - if p.auditMode { - secretLogger.Error(err, "Audit mode: Signature verification failed, allowing anyway") - return &VerificationResult{ - Verified: true, - Details: fmt.Sprintf("Signature verification failed (audit mode: allowed): %v", err), - }, nil - } - return &VerificationResult{ - Verified: false, - Details: err.Error(), - }, err -} - -// getKeyFromSecret retrieves the appropriate public key from the secret -func (p *SecretProvider) getKeyFromSecret(secret *corev1.Secret, keyID string) ([]byte, error) { - // If a specific key is configured, use that - if p.secretKey != "" { - if data, ok := secret.Data[p.secretKey]; ok { - return data, nil - } - return nil, fmt.Errorf("key %s not found in secret", p.secretKey) - } - - // If keyID is specified in the signature, try to find it - if keyID != "" { - if data, ok := secret.Data[keyID]; ok { - return data, nil - } - // Try common PEM key file extensions used by cert-manager, openssl, and ssh-keygen - for _, ext := range []string{".pem", ".pub", ".key"} { - if data, ok := secret.Data[keyID+ext]; ok { - return data, nil - } - } - return nil, fmt.Errorf("key with ID %s not found in secret", keyID) - } - - // Try common key names - for _, keyName := range []string{"public.pem", "publickey.pem", "key.pem", "public-key"} { - if data, ok := secret.Data[keyName]; ok { - return data, nil - } - } - - // If only one key in secret, use that - if len(secret.Data) == 1 { - for _, data := range secret.Data { - return data, nil - } - } - - return nil, fmt.Errorf("no suitable public key found in secret") -} - -// Name returns the provider name -func (p *SecretProvider) Name() string { - return "secret" -} diff --git a/kagenti-operator/internal/signature/secret_test.go b/kagenti-operator/internal/signature/secret_test.go deleted file mode 100644 index 86e9c282..00000000 --- a/kagenti-operator/internal/signature/secret_test.go +++ /dev/null @@ -1,428 +0,0 @@ -/* -Copyright 2025. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package signature - -import ( - "context" - "crypto/rsa" - "testing" - - agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -// --- Test helpers --- - -// newSecretProviderForTest creates a SecretProvider with a fake K8s client -// that has the given secret pre-loaded. -func newSecretProviderForTest(t *testing.T, secret *corev1.Secret, config *Config) *SecretProvider { - t.Helper() - scheme := runtime.NewScheme() - if err := corev1.AddToScheme(scheme); err != nil { - t.Fatalf("Failed to add corev1 to scheme: %v", err) - } - - objs := []runtime.Object{} - if secret != nil { - objs = append(objs, secret) - } - fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(objs...).Build() - - p, err := NewSecretProvider(config) - if err != nil { - t.Fatalf("NewSecretProvider failed: %v", err) - } - sp := p.(*SecretProvider) - sp.SetClient(fakeClient) - return sp -} - -// rsaPubKeyPEM delegates to generateRSAKeyPair in verifier_test.go (same package). -func rsaPubKeyPEM(t *testing.T) (*rsa.PrivateKey, []byte) { - t.Helper() - return generateRSAKeyPair(t) -} - -// --- SecretProvider.VerifySignature tests (JWS format) --- - -func TestSecretProvider_ValidJWSSignature(t *testing.T) { - privKey, pubPEM := rsaPubKeyPEM(t) - cardData := newCardData("Agent", "http://agent:8000", "1.0.0") - jwsSig := buildJWSSignature(t, cardData, privKey, "my-key", "") - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "keys", Namespace: "system"}, - Data: map[string][]byte{"my-key": pubPEM}, - } - sp := newSecretProviderForTest(t, secret, &Config{ - Type: ProviderTypeSecret, - SecretName: "keys", - SecretNamespace: "system", - }) - - result, err := sp.VerifySignature(context.Background(), cardData, - []agentv1alpha1.AgentCardSignature{jwsSig}) - - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if !result.Verified { - t.Errorf("Expected verified=true. Details: %s", result.Details) - } -} - -func TestSecretProvider_EmptySignatures_RejectMode(t *testing.T) { - _, pubPEM := rsaPubKeyPEM(t) - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "keys", Namespace: "system"}, - Data: map[string][]byte{"key": pubPEM}, - } - sp := newSecretProviderForTest(t, secret, &Config{ - Type: ProviderTypeSecret, - SecretName: "keys", - SecretNamespace: "system", - AuditMode: false, - }) - - result, _ := sp.VerifySignature(context.Background(), - newCardData("A", "http://a:8000", "1.0"), - nil) // no signatures - if result.Verified { - t.Error("Expected verified=false for empty signatures in reject mode") - } -} - -func TestSecretProvider_EmptySignatures_AuditMode(t *testing.T) { - _, pubPEM := rsaPubKeyPEM(t) - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "keys", Namespace: "system"}, - Data: map[string][]byte{"key": pubPEM}, - } - sp := newSecretProviderForTest(t, secret, &Config{ - Type: ProviderTypeSecret, - SecretName: "keys", - SecretNamespace: "system", - AuditMode: true, - }) - - result, err := sp.VerifySignature(context.Background(), - newCardData("A", "http://a:8000", "1.0"), - nil) // no signatures - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if !result.Verified { - t.Error("Expected verified=true for empty signatures in audit mode") - } - if indexOf(result.Details, "audit mode") < 0 { - t.Errorf("Expected details to mention audit mode, got: %s", result.Details) - } -} - -func TestSecretProvider_WrongKey(t *testing.T) { - privKey, _ := rsaPubKeyPEM(t) - _, wrongPubPEM := rsaPubKeyPEM(t) // different key - - cardData := newCardData("Agent", "http://agent:8000", "1.0.0") - jwsSig := buildJWSSignature(t, cardData, privKey, "my-key", "") - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "keys", Namespace: "system"}, - Data: map[string][]byte{"my-key": wrongPubPEM}, - } - sp := newSecretProviderForTest(t, secret, &Config{ - Type: ProviderTypeSecret, - SecretName: "keys", - SecretNamespace: "system", - }) - - result, _ := sp.VerifySignature(context.Background(), cardData, - []agentv1alpha1.AgentCardSignature{jwsSig}) - - if result.Verified { - t.Error("Expected verified=false when signing key doesn't match verification key") - } -} - -func TestSecretProvider_FallbackKeyRotation(t *testing.T) { - // Card signed with old key. Secret has "my-key" updated to new key, - // but old key still present under "old-key". Fallback should find it. - privKey, pubPEM := rsaPubKeyPEM(t) - _, newPubPEM := rsaPubKeyPEM(t) - - cardData := newCardData("Agent", "http://agent:8000", "1.0.0") - jwsSig := buildJWSSignature(t, cardData, privKey, "my-key", "") - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "keys", Namespace: "system"}, - Data: map[string][]byte{ - "my-key": newPubPEM, // primary — doesn't match - "old-key": pubPEM, // fallback — matches - }, - } - sp := newSecretProviderForTest(t, secret, &Config{ - Type: ProviderTypeSecret, - SecretName: "keys", - SecretNamespace: "system", - }) - - result, err := sp.VerifySignature(context.Background(), cardData, - []agentv1alpha1.AgentCardSignature{jwsSig}) - - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if !result.Verified { - t.Error("Expected verified=true via fallback key rotation") - } - if result.KeyID != "old-key" { - t.Errorf("Expected keyID='old-key' (the key that matched), got '%s'", result.KeyID) - } -} - -func TestSecretProvider_SecretNotFound(t *testing.T) { - sp := newSecretProviderForTest(t, nil, &Config{ - Type: ProviderTypeSecret, - SecretName: "nonexistent", - SecretNamespace: "system", - }) - - header := &ProtectedHeader{Algorithm: "RS256", KeyID: "key-1"} - protB64, _ := EncodeProtectedHeader(header) - - result, err := sp.VerifySignature(context.Background(), - newCardData("A", "http://a:8000", "1.0"), - []agentv1alpha1.AgentCardSignature{{Protected: protB64, Signature: "fake"}}) - - if err == nil { - t.Error("Expected error when secret is not found") - } - if result.Verified { - t.Error("Expected verified=false when secret is not found") - } -} - -func TestSecretProvider_SecretNotFound_AuditMode(t *testing.T) { - sp := newSecretProviderForTest(t, nil, &Config{ - Type: ProviderTypeSecret, - SecretName: "nonexistent", - SecretNamespace: "system", - AuditMode: true, - }) - - header := &ProtectedHeader{Algorithm: "RS256", KeyID: "key-1"} - protB64, _ := EncodeProtectedHeader(header) - - result, err := sp.VerifySignature(context.Background(), - newCardData("A", "http://a:8000", "1.0"), - []agentv1alpha1.AgentCardSignature{{Protected: protB64, Signature: "fake"}}) - - if err != nil { - t.Fatalf("Unexpected error in audit mode: %v", err) - } - if !result.Verified { - t.Error("Expected verified=true in audit mode even when secret is missing") - } -} - -func TestSecretProvider_ClientNotSet(t *testing.T) { - p, err := NewSecretProvider(&Config{ - Type: ProviderTypeSecret, - SecretName: "keys", - SecretNamespace: "system", - }) - if err != nil { - t.Fatalf("NewSecretProvider failed: %v", err) - } - // Don't call SetClient — client is nil - - header := &ProtectedHeader{Algorithm: "RS256", KeyID: "key-1"} - protB64, _ := EncodeProtectedHeader(header) - - result, err := p.VerifySignature(context.Background(), - newCardData("A", "http://a:8000", "1.0"), - []agentv1alpha1.AgentCardSignature{{Protected: protB64, Signature: "fake"}}) - - if err == nil { - t.Error("Expected error when client is not set") - } - if result.Verified { - t.Error("Expected verified=false when client is not set") - } -} - -func TestSecretProvider_Name(t *testing.T) { - p, _ := NewSecretProvider(&Config{ - Type: ProviderTypeSecret, - SecretName: "keys", - SecretNamespace: "system", - }) - if p.Name() != "secret" { - t.Errorf("Expected 'secret', got '%s'", p.Name()) - } -} - -func TestSecretProvider_SpiffeIDExtracted(t *testing.T) { - privKey, pubPEM := rsaPubKeyPEM(t) - cardData := newCardData("Agent", "http://agent:8000", "1.0.0") - spiffeID := "spiffe://cluster.local/ns/default/sa/agent-sa" - jwsSig := buildJWSSignature(t, cardData, privKey, "my-key", spiffeID) - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "keys", Namespace: "system"}, - Data: map[string][]byte{"my-key": pubPEM}, - } - sp := newSecretProviderForTest(t, secret, &Config{ - Type: ProviderTypeSecret, - SecretName: "keys", - SecretNamespace: "system", - }) - - result, err := sp.VerifySignature(context.Background(), cardData, - []agentv1alpha1.AgentCardSignature{jwsSig}) - - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if !result.Verified { - t.Errorf("Expected verified=true. Details: %s", result.Details) - } - if result.SpiffeID != spiffeID { - t.Errorf("Expected SpiffeID=%s, got %s", spiffeID, result.SpiffeID) - } -} - -// --- getKeyFromSecret tests --- - -func TestGetKeyFromSecret_BySecretKey(t *testing.T) { - sp := &SecretProvider{secretKey: "my-custom-key"} - secret := &corev1.Secret{ - Data: map[string][]byte{ - "my-custom-key": []byte("key-data"), - "other-key": []byte("other-data"), - }, - } - - data, err := sp.getKeyFromSecret(secret, "ignored-keyID") - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if string(data) != "key-data" { - t.Errorf("Expected 'key-data', got '%s'", data) - } -} - -func TestGetKeyFromSecret_ByKeyID(t *testing.T) { - sp := &SecretProvider{} - secret := &corev1.Secret{ - Data: map[string][]byte{ - "signing-key": []byte("key-data-1"), - "another-key": []byte("key-data-2"), - }, - } - - data, err := sp.getKeyFromSecret(secret, "signing-key") - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if string(data) != "key-data-1" { - t.Errorf("Expected 'key-data-1', got '%s'", data) - } -} - -func TestGetKeyFromSecret_ByKeyID_WithExtension(t *testing.T) { - sp := &SecretProvider{} - secret := &corev1.Secret{ - Data: map[string][]byte{ - "my-key.pem": []byte("key-data"), - }, - } - - data, err := sp.getKeyFromSecret(secret, "my-key") - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if string(data) != "key-data" { - t.Errorf("Expected 'key-data', got '%s'", data) - } -} - -func TestGetKeyFromSecret_CommonKeyNames(t *testing.T) { - sp := &SecretProvider{} - secret := &corev1.Secret{ - Data: map[string][]byte{ - "public.pem": []byte("key-data"), - }, - } - - data, err := sp.getKeyFromSecret(secret, "") - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if string(data) != "key-data" { - t.Errorf("Expected 'key-data', got '%s'", data) - } -} - -func TestGetKeyFromSecret_SingleKey(t *testing.T) { - sp := &SecretProvider{} - secret := &corev1.Secret{ - Data: map[string][]byte{ - "whatever-name": []byte("the-only-key"), - }, - } - - data, err := sp.getKeyFromSecret(secret, "") - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if string(data) != "the-only-key" { - t.Errorf("Expected 'the-only-key', got '%s'", data) - } -} - -func TestGetKeyFromSecret_NoMatch(t *testing.T) { - sp := &SecretProvider{} - secret := &corev1.Secret{ - Data: map[string][]byte{ - "key-a": []byte("a"), - "key-b": []byte("b"), - }, - } - - _, err := sp.getKeyFromSecret(secret, "") - if err == nil { - t.Error("Expected error when no suitable key found") - } -} - -func TestGetKeyFromSecret_MissingSecretKey(t *testing.T) { - sp := &SecretProvider{secretKey: "nonexistent"} - secret := &corev1.Secret{ - Data: map[string][]byte{ - "actual-key": []byte("data"), - }, - } - - _, err := sp.getKeyFromSecret(secret, "") - if err == nil { - t.Error("Expected error when configured secretKey doesn't exist") - } -} diff --git a/kagenti-operator/internal/signature/verifier.go b/kagenti-operator/internal/signature/verifier.go index a3f9324f..496421c8 100644 --- a/kagenti-operator/internal/signature/verifier.go +++ b/kagenti-operator/internal/signature/verifier.go @@ -22,8 +22,8 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rsa" - _ "crypto/sha256" // Register SHA-256 for crypto.SHA256.New() - _ "crypto/sha512" // Register SHA-384 and SHA-512 for crypto.SHA384.New() / crypto.SHA512.New() + _ "crypto/sha256" + _ "crypto/sha512" "crypto/x509" "encoding/base64" "encoding/json" @@ -35,24 +35,14 @@ import ( agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" ) -// ProtectedHeader represents the decoded JWS protected header. -// Per A2A spec section 8.4.2, the protected header MUST contain: -// - alg: the signature algorithm (e.g. "RS256", "ES256") -// - typ: SHOULD be "JOSE" for JWS -// - kid: the key identifier -// -// And MAY contain: -// - jku: URL to JWKS containing the public key -// - spiffe_id: SPIFFE identity of the signer (extension for identity binding) +// ProtectedHeader represents the decoded JWS protected header (A2A spec section 8.4.2). type ProtectedHeader struct { - Algorithm string `json:"alg"` - Type string `json:"typ,omitempty"` - KeyID string `json:"kid,omitempty"` - JWKSURL string `json:"jku,omitempty"` - SpiffeID string `json:"spiffe_id,omitempty"` + Algorithm string `json:"alg"` + Type string `json:"typ,omitempty"` + KeyID string `json:"kid,omitempty"` + X5C []string `json:"x5c,omitempty"` // X.509 certificate chain (base64, NOT base64url) per RFC 7515 §4.1.6 } -// DecodeProtectedHeader decodes a base64url-encoded JWS protected header. func DecodeProtectedHeader(protected string) (*ProtectedHeader, error) { headerJSON, err := base64.RawURLEncoding.DecodeString(protected) if err != nil { @@ -65,7 +55,6 @@ func DecodeProtectedHeader(protected string) (*ProtectedHeader, error) { return &header, nil } -// EncodeProtectedHeader encodes a ProtectedHeader to a base64url string. func EncodeProtectedHeader(header *ProtectedHeader) (string, error) { headerJSON, err := json.Marshal(header) if err != nil { @@ -74,11 +63,7 @@ func EncodeProtectedHeader(header *ProtectedHeader) (string, error) { return base64.RawURLEncoding.EncodeToString(headerJSON), nil } -// VerifyJWS verifies a single JWS signature against card data using a public key (PEM format). -// This follows A2A spec section 8.4.3: -// -// signingInput = BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload) -// where JWS Payload = canonical JSON of card data excluding "signatures" +// VerifyJWS verifies a JWS signature against card data using a PEM public key. func VerifyJWS(cardData *agentv1alpha1.AgentCardData, sig *agentv1alpha1.AgentCardSignature, publicKeyPEM []byte) (*VerificationResult, error) { if sig == nil { return &VerificationResult{ @@ -87,7 +72,6 @@ func VerifyJWS(cardData *agentv1alpha1.AgentCardData, sig *agentv1alpha1.AgentCa }, nil } - // Decode the protected header to extract alg, kid, spiffe_id header, err := DecodeProtectedHeader(sig.Protected) if err != nil { return &VerificationResult{ @@ -96,7 +80,6 @@ func VerifyJWS(cardData *agentv1alpha1.AgentCardData, sig *agentv1alpha1.AgentCa }, err } - // Reject "none" and unsupported algorithms (RFC 7515 §5.2). if err := validateAlgorithm(header.Algorithm); err != nil { return &VerificationResult{ Verified: false, @@ -104,7 +87,6 @@ func VerifyJWS(cardData *agentv1alpha1.AgentCardData, sig *agentv1alpha1.AgentCa }, err } - // Parse the public key block, _ := pem.Decode(publicKeyPEM) if block == nil { return &VerificationResult{ @@ -121,9 +103,6 @@ func VerifyJWS(cardData *agentv1alpha1.AgentCardData, sig *agentv1alpha1.AgentCa }, err } - // Enforce minimum RSA key size (2048 bits) regardless of provider. - // The JWKS provider already checks this during key conversion, but the - // SecretProvider does not — centralising the check here protects both paths. if rsaKey, ok := publicKey.(*rsa.PublicKey); ok { if rsaKey.N.BitLen() < 2048 { return &VerificationResult{ @@ -133,8 +112,7 @@ func VerifyJWS(cardData *agentv1alpha1.AgentCardData, sig *agentv1alpha1.AgentCa } } - // Create canonical payload (card JSON excluding "signatures") - payload, err := createCanonicalCardJSON(cardData) + payload, err := CreateCanonicalCardJSON(cardData) if err != nil { return &VerificationResult{ Verified: false, @@ -142,14 +120,11 @@ func VerifyJWS(cardData *agentv1alpha1.AgentCardData, sig *agentv1alpha1.AgentCa }, err } - // Construct JWS signing input per RFC 7515: - // ASCII(BASE64URL(UTF8(JWS Protected Header))) || '.' || ASCII(BASE64URL(JWS Payload)) + // JWS signing input per RFC 7515 §5.2 payloadB64 := base64.RawURLEncoding.EncodeToString(payload) signingInput := []byte(sig.Protected + "." + payloadB64) - // Select the hash function from the algorithm per RFC 7518: - // *S256 → SHA-256, *S384 → SHA-384, *S512 → SHA-512 - hashFunc, err := hashForAlgorithm(header.Algorithm) + hashFunc, err := HashForAlgorithm(header.Algorithm) if err != nil { return &VerificationResult{ Verified: false, @@ -157,12 +132,10 @@ func VerifyJWS(cardData *agentv1alpha1.AgentCardData, sig *agentv1alpha1.AgentCa }, err } - // Hash the signing input hasher := hashFunc.New() hasher.Write(signingInput) hashed := hasher.Sum(nil) - // Decode the signature value (base64url, no padding) signatureBytes, err := base64.RawURLEncoding.DecodeString(sig.Signature) if err != nil { return &VerificationResult{ @@ -171,7 +144,7 @@ func VerifyJWS(cardData *agentv1alpha1.AgentCardData, sig *agentv1alpha1.AgentCa }, err } - // Verify signature; also check alg matches key type to prevent algorithm confusion. + // Enforce alg-key type match to prevent algorithm confusion. var verified bool switch pub := publicKey.(type) { case *rsa.PublicKey: @@ -181,8 +154,6 @@ func VerifyJWS(cardData *agentv1alpha1.AgentCardData, sig *agentv1alpha1.AgentCa Details: fmt.Sprintf("Algorithm mismatch: protected header specifies %q but public key is RSA (expected RS256/RS384/RS512/PS256/PS384/PS512)", header.Algorithm), }, fmt.Errorf("algorithm mismatch: header alg=%q but key is RSA", header.Algorithm) } - // RS256/RS384/RS512 → PKCS#1 v1.5 padding - // PS256/PS384/PS512 → RSA-PSS padding (RFC 7518 §3.5) if isPSSAlgorithm(header.Algorithm) { err = rsa.VerifyPSS(pub, hashFunc, hashed, signatureBytes, &rsa.PSSOptions{ SaltLength: rsa.PSSSaltLengthEqualsHash, @@ -198,18 +169,15 @@ func VerifyJWS(cardData *agentv1alpha1.AgentCardData, sig *agentv1alpha1.AgentCa Details: fmt.Sprintf("Algorithm mismatch: protected header specifies %q but public key is ECDSA (expected ES256/ES384/ES512)", header.Algorithm), }, fmt.Errorf("algorithm mismatch: header alg=%q but key is ECDSA", header.Algorithm) } - // Validate curve matches algorithm (ES256→P-256, ES384→P-384, ES512→P-521) if err := validateECDSACurve(pub, header.Algorithm); err != nil { return &VerificationResult{ Verified: false, Details: fmt.Sprintf("ECDSA curve/algorithm mismatch: %v", err), }, err } - // JWS ES256/ES384/ES512 uses raw R||S encoding (not ASN.1 DER). - // Try raw R||S first (spec-compliant), fall back to ASN.1 DER. + // JWS uses raw R||S encoding; fall back to ASN.1 DER for compatibility. verified = verifyECDSARaw(pub, hashed, signatureBytes) if !verified { - // Fallback: try ASN.1 DER encoding for backward compatibility verified = ecdsa.VerifyASN1(pub, hashed, signatureBytes) } default: @@ -222,7 +190,6 @@ func VerifyJWS(cardData *agentv1alpha1.AgentCardData, sig *agentv1alpha1.AgentCa result := &VerificationResult{ Verified: verified, KeyID: header.KeyID, - SpiffeID: header.SpiffeID, } if !verified { @@ -234,11 +201,9 @@ func VerifyJWS(cardData *agentv1alpha1.AgentCardData, sig *agentv1alpha1.AgentCa return result, nil } -// verifyECDSARaw verifies an ECDSA signature in JWS raw R||S format. -// Per RFC 7518 section 3.4, ES256 signatures are 64 bytes (32 + 32), -// ES384 are 96 bytes, ES512 are 132 bytes. +// verifyECDSARaw verifies ECDSA in JWS raw R||S format (RFC 7518 §3.4). func verifyECDSARaw(pub *ecdsa.PublicKey, hash, sig []byte) bool { - keySize := curveByteSize(pub.Curve) + keySize := CurveByteSize(pub.Curve) if len(sig) != 2*keySize { return false } @@ -248,21 +213,16 @@ func verifyECDSARaw(pub *ecdsa.PublicKey, hash, sig []byte) bool { return ecdsa.Verify(pub, hash, r, s) } -// curveByteSize returns the byte size for a curve's field elements. -func curveByteSize(curve elliptic.Curve) int { - bitSize := curve.Params().BitSize - return (bitSize + 7) / 8 +func CurveByteSize(curve elliptic.Curve) int { + return (curve.Params().BitSize + 7) / 8 } -// supportedAlgorithms is the set of JWS algorithms we accept. -// Per RFC 7515 §5.2, verifiers MUST reject algorithms they don't support. var supportedAlgorithms = map[string]bool{ "RS256": true, "RS384": true, "RS512": true, "PS256": true, "PS384": true, "PS512": true, "ES256": true, "ES384": true, "ES512": true, } -// validateAlgorithm rejects "none" and unsupported algorithms. func validateAlgorithm(alg string) error { if alg == "" { return fmt.Errorf("JWS protected header missing required 'alg' field") @@ -276,7 +236,6 @@ func validateAlgorithm(alg string) error { return nil } -// isRSAAlgorithm returns true if the JWS algorithm corresponds to an RSA key. func isRSAAlgorithm(alg string) bool { switch alg { case "RS256", "RS384", "RS512", "PS256", "PS384", "PS512": @@ -285,7 +244,6 @@ func isRSAAlgorithm(alg string) bool { return false } -// isECDSAAlgorithm returns true if the JWS algorithm corresponds to an ECDSA key. func isECDSAAlgorithm(alg string) bool { switch alg { case "ES256", "ES384", "ES512": @@ -294,7 +252,6 @@ func isECDSAAlgorithm(alg string) bool { return false } -// isPSSAlgorithm returns true if the JWS algorithm uses RSA-PSS padding. func isPSSAlgorithm(alg string) bool { switch alg { case "PS256", "PS384", "PS512": @@ -303,12 +260,7 @@ func isPSSAlgorithm(alg string) bool { return false } -// hashForAlgorithm returns the crypto.Hash for a given JWS algorithm per RFC 7518. -// -// RS256/PS256/ES256 → SHA-256 -// RS384/PS384/ES384 → SHA-384 -// RS512/PS512/ES512 → SHA-512 -func hashForAlgorithm(alg string) (crypto.Hash, error) { +func HashForAlgorithm(alg string) (crypto.Hash, error) { switch alg { case "RS256", "PS256", "ES256": return crypto.SHA256, nil @@ -321,9 +273,7 @@ func hashForAlgorithm(alg string) (crypto.Hash, error) { } } -// validateECDSACurve checks that the ECDSA key's curve matches the algorithm per RFC 7518 §3.4: -// -// ES256 → P-256, ES384 → P-384, ES512 → P-521 +// validateECDSACurve enforces ES256→P-256, ES384→P-384, ES512→P-521 (RFC 7518 §3.4). func validateECDSACurve(pub *ecdsa.PublicKey, alg string) error { var expectedCurve elliptic.Curve switch alg { @@ -343,8 +293,21 @@ func validateECDSACurve(pub *ecdsa.PublicKey, alg string) error { return nil } -// parsePublicKey parses a public key from DER format. -// Tries PKIX (SubjectPublicKeyInfo) first, then falls back to PKCS#1 RSA format. +func MarshalPublicKeyToPEM(publicKey interface{}) ([]byte, error) { + pkixBytes, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal public key: %w", err) + } + + pemBlock := &pem.Block{ + Type: "PUBLIC KEY", + Bytes: pkixBytes, + } + + return pem.EncodeToMemory(pemBlock), nil +} + +// parsePublicKey tries PKIX first, then falls back to PKCS#1 RSA. func parsePublicKey(derBytes []byte) (crypto.PublicKey, error) { if key, err := x509.ParsePKIXPublicKey(derBytes); err == nil { return key, nil @@ -358,43 +321,25 @@ func parsePublicKey(derBytes []byte) (crypto.PublicKey, error) { return nil, fmt.Errorf("failed to parse public key (tried PKIX and PKCS#1): %w", err) } -// CreateCanonicalCardJSON is the exported entry point for building the JWS payload. -// Tests should use this instead of maintaining a parallel canonical JSON implementation. +// CreateCanonicalCardJSON builds the JWS payload: sorted-key, compact JSON +// with the "signatures" field excluded. func CreateCanonicalCardJSON(cardData *agentv1alpha1.AgentCardData) ([]byte, error) { - return createCanonicalCardJSON(cardData) -} - -// createCanonicalCardJSON builds the JWS payload: sorted-key, compact JSON -// of the card data with the "signatures" field excluded. -func createCanonicalCardJSON(cardData *agentv1alpha1.AgentCardData) ([]byte, error) { - // Marshal the full struct to JSON rawJSON, err := json.Marshal(cardData) if err != nil { return nil, fmt.Errorf("failed to marshal card data: %w", err) } - // Unmarshal to generic map var cardMap map[string]interface{} if err := json.Unmarshal(rawJSON, &cardMap); err != nil { return nil, fmt.Errorf("failed to unmarshal to map: %w", err) } - // Remove the signatures field — it must not be part of the signed payload delete(cardMap, "signatures") - - // Remove empty/nil fields to match Python behavior where absent fields are not included cleanMap := removeEmptyFields(cardMap) - - // Produce canonical JSON with sorted keys return marshalCanonical(cleanMap) } -// removeEmptyFields strips nil values and empty collections to match -// the Python signer's behavior of omitting absent fields. -// -// WARNING: This strips absent/empty fields to match sign-agent-card.py. -// If the A2A spec changes to require explicit empty values in the payload, -// this function must be updated alongside sign-agent-card.py. +// removeEmptyFields strips nil/empty values for canonical JSON. func removeEmptyFields(m map[string]interface{}) map[string]interface{} { result := make(map[string]interface{}) for k, v := range m { @@ -416,14 +361,12 @@ func removeEmptyFields(m map[string]interface{}) map[string]interface{} { result[k] = val } default: - // bool (false), float64 (0), and other non-nil/non-empty types are preserved. result[k] = v } } return result } -// marshalCanonical marshals a map to compact JSON with sorted keys. func marshalCanonical(data map[string]interface{}) ([]byte, error) { var buf bytes.Buffer @@ -455,7 +398,6 @@ func marshalCanonical(data map[string]interface{}) ([]byte, error) { return buf.Bytes(), nil } -// marshalValue marshals a value with sorted keys if it's a map. func marshalValue(v interface{}) ([]byte, error) { switch val := v.(type) { case map[string]interface{}: @@ -482,10 +424,7 @@ func marshalValue(v interface{}) ([]byte, error) { } } -// toGenericValue converts any value to a generic JSON-compatible type via a -// Marshal → Unmarshal round-trip. This fallback path is only hit for types -// not handled by marshalValue's explicit type switch (e.g., json.Number or -// custom types produced by unusual JSON decoders). +// toGenericValue converts unknown types via a Marshal→Unmarshal round-trip. func toGenericValue(v interface{}) (interface{}, error) { jsonBytes, err := json.Marshal(v) if err != nil { @@ -498,7 +437,6 @@ func toGenericValue(v interface{}) (interface{}, error) { return generic, nil } -// marshalArray marshals an array with proper handling of nested objects. func marshalArray(arr []interface{}) ([]byte, error) { var buf bytes.Buffer buf.WriteByte('[') diff --git a/kagenti-operator/internal/signature/verifier_test.go b/kagenti-operator/internal/signature/verifier_test.go index fb1a3fcf..e7deb7ab 100644 --- a/kagenti-operator/internal/signature/verifier_test.go +++ b/kagenti-operator/internal/signature/verifier_test.go @@ -23,12 +23,11 @@ import ( "crypto/rand" "crypto/rsa" "crypto/sha256" - _ "crypto/sha512" // Register SHA-384 and SHA-512 for hashForAlgorithm tests + _ "crypto/sha512" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" - "math/big" "testing" agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" @@ -69,20 +68,19 @@ func generateECDSAKeyPair(t *testing.T) (*ecdsa.PrivateKey, []byte) { // buildJWSSignature creates a JWS signature for testing. // It builds a protected header, constructs the signing input per RFC 7515, // and signs with the given RSA private key. -func buildJWSSignature(t *testing.T, cardData *agentv1alpha1.AgentCardData, privKey *rsa.PrivateKey, kid, spiffeID string) agentv1alpha1.AgentCardSignature { +func buildJWSSignature(t *testing.T, cardData *agentv1alpha1.AgentCardData, privKey *rsa.PrivateKey, kid, _ string) agentv1alpha1.AgentCardSignature { t.Helper() header := &ProtectedHeader{ Algorithm: "RS256", Type: "JOSE", KeyID: kid, - SpiffeID: spiffeID, } protectedB64, err := EncodeProtectedHeader(header) if err != nil { t.Fatalf("Failed to encode protected header: %v", err) } - payload, err := createCanonicalCardJSON(cardData) + payload, err := CreateCanonicalCardJSON(cardData) if err != nil { t.Fatalf("Failed to create canonical JSON: %v", err) } @@ -114,7 +112,7 @@ func buildJWSSignatureECDSA(t *testing.T, cardData *agentv1alpha1.AgentCardData, t.Fatalf("Failed to encode protected header: %v", err) } - payload, err := createCanonicalCardJSON(cardData) + payload, err := CreateCanonicalCardJSON(cardData) if err != nil { t.Fatalf("Failed to create canonical JSON: %v", err) } @@ -148,7 +146,6 @@ func TestDecodeProtectedHeader(t *testing.T) { header := &ProtectedHeader{ Algorithm: "RS256", KeyID: "test-key", - SpiffeID: "spiffe://cluster.local/ns/default/sa/agent", } encoded, err := EncodeProtectedHeader(header) if err != nil { @@ -165,9 +162,6 @@ func TestDecodeProtectedHeader(t *testing.T) { if decoded.KeyID != "test-key" { t.Errorf("Expected kid=test-key, got %s", decoded.KeyID) } - if decoded.SpiffeID != "spiffe://cluster.local/ns/default/sa/agent" { - t.Errorf("Expected spiffe_id match, got %s", decoded.SpiffeID) - } } func TestDecodeProtectedHeader_InvalidBase64(t *testing.T) { @@ -194,9 +188,9 @@ func TestCanonicalJSON_SortedKeys(t *testing.T) { Version: "1.0.0", } - canonical, err := createCanonicalCardJSON(cardData) + canonical, err := CreateCanonicalCardJSON(cardData) if err != nil { - t.Fatalf("createCanonicalCardJSON failed: %v", err) + t.Fatalf("CreateCanonicalCardJSON failed: %v", err) } // Verify it's valid JSON @@ -232,9 +226,9 @@ func TestCanonicalJSON_ExcludesSignatures(t *testing.T) { }, } - canonical, err := createCanonicalCardJSON(cardData) + canonical, err := CreateCanonicalCardJSON(cardData) if err != nil { - t.Fatalf("createCanonicalCardJSON failed: %v", err) + t.Fatalf("CreateCanonicalCardJSON failed: %v", err) } got := string(canonical) @@ -252,9 +246,9 @@ func TestCanonicalJSON_ExcludesEmptyFields(t *testing.T) { Version: "1.0.0", } - canonical, err := createCanonicalCardJSON(cardData) + canonical, err := CreateCanonicalCardJSON(cardData) if err != nil { - t.Fatalf("createCanonicalCardJSON failed: %v", err) + t.Fatalf("CreateCanonicalCardJSON failed: %v", err) } got := string(canonical) @@ -276,13 +270,13 @@ func TestCanonicalJSON_Deterministic(t *testing.T) { DefaultOutputModes: []string{"application/json"}, } - first, err := createCanonicalCardJSON(cardData) + first, err := CreateCanonicalCardJSON(cardData) if err != nil { t.Fatalf("First call failed: %v", err) } for i := 0; i < 10; i++ { - again, err := createCanonicalCardJSON(cardData) + again, err := CreateCanonicalCardJSON(cardData) if err != nil { t.Fatalf("Call %d failed: %v", i, err) } @@ -303,9 +297,9 @@ func TestCanonicalJSON_NestedCapabilities(t *testing.T) { }, } - canonical, err := createCanonicalCardJSON(cardData) + canonical, err := CreateCanonicalCardJSON(cardData) if err != nil { - t.Fatalf("createCanonicalCardJSON failed: %v", err) + t.Fatalf("CreateCanonicalCardJSON failed: %v", err) } got := string(canonical) @@ -338,24 +332,6 @@ func TestVerifyJWS_RSA_ValidSignature(t *testing.T) { } } -func TestVerifyJWS_RSA_WithSpiffeID(t *testing.T) { - privKey, pubKeyPEM := generateRSAKeyPair(t) - cardData := newCardData("Agent", "http://agent:8000", "1.0.0") - spiffeID := "spiffe://cluster.local/ns/default/sa/agent-sa" - jwsSig := buildJWSSignature(t, cardData, privKey, "key-1", spiffeID) - - result, err := VerifyJWS(cardData, &jwsSig, pubKeyPEM) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if !result.Verified { - t.Errorf("Expected verified=true. Details: %s", result.Details) - } - if result.SpiffeID != spiffeID { - t.Errorf("Expected SpiffeID=%s, got %s", spiffeID, result.SpiffeID) - } -} - func TestVerifyJWS_RSA_WrongKey(t *testing.T) { privKey, _ := generateRSAKeyPair(t) _, wrongPubKeyPEM := generateRSAKeyPair(t) @@ -488,17 +464,6 @@ func TestVerifyJWS_InvalidSignatureBase64(t *testing.T) { } } -func TestVerifyJWS_PreservesKeyID(t *testing.T) { - privKey, pubKeyPEM := generateRSAKeyPair(t) - cardData := newCardData("Agent", "http://agent:8000", "1.0.0") - jwsSig := buildJWSSignature(t, cardData, privKey, "my-special-key-id", "") - - result, _ := VerifyJWS(cardData, &jwsSig, pubKeyPEM) - if result.KeyID != "my-special-key-id" { - t.Errorf("Expected keyID='my-special-key-id', got '%s'", result.KeyID) - } -} - // --- Cross-algorithm compatibility test --- func TestVerifyJWS_RSAKeyWithECDSASignature(t *testing.T) { @@ -638,16 +603,16 @@ func buildJWSSignatureRSAGeneric(t *testing.T, cardData *agentv1alpha1.AgentCard t.Fatalf("Failed to encode protected header: %v", err) } - payload, err := createCanonicalCardJSON(cardData) + payload, err := CreateCanonicalCardJSON(cardData) if err != nil { t.Fatalf("Failed to create canonical JSON: %v", err) } payloadB64 := base64.RawURLEncoding.EncodeToString(payload) signingInput := []byte(protectedB64 + "." + payloadB64) - hashFunc, err := hashForAlgorithm(alg) + hashFunc, err := HashForAlgorithm(alg) if err != nil { - t.Fatalf("hashForAlgorithm(%s) failed: %v", alg, err) + t.Fatalf("HashForAlgorithm(%s) failed: %v", alg, err) } hasher := hashFunc.New() hasher.Write(signingInput) @@ -685,16 +650,16 @@ func buildJWSSignatureECDSAGeneric(t *testing.T, cardData *agentv1alpha1.AgentCa t.Fatalf("Failed to encode protected header: %v", err) } - payload, err := createCanonicalCardJSON(cardData) + payload, err := CreateCanonicalCardJSON(cardData) if err != nil { t.Fatalf("Failed to create canonical JSON: %v", err) } payloadB64 := base64.RawURLEncoding.EncodeToString(payload) signingInput := []byte(protectedB64 + "." + payloadB64) - hashFunc, err := hashForAlgorithm(alg) + hashFunc, err := HashForAlgorithm(alg) if err != nil { - t.Fatalf("hashForAlgorithm(%s) failed: %v", alg, err) + t.Fatalf("HashForAlgorithm(%s) failed: %v", alg, err) } hasher := hashFunc.New() hasher.Write(signingInput) @@ -842,7 +807,7 @@ func TestVerifyJWS_ECDSA_CurveMismatch(t *testing.T) { header := &ProtectedHeader{Algorithm: "ES256", Type: "JOSE", KeyID: "mismatch-key"} protectedB64, _ := EncodeProtectedHeader(header) - payload, _ := createCanonicalCardJSON(cardData) + payload, _ := CreateCanonicalCardJSON(cardData) payloadB64 := base64.RawURLEncoding.EncodeToString(payload) signingInput := []byte(protectedB64 + "." + payloadB64) @@ -883,9 +848,9 @@ func TestCanonicalJSON_BoolFalse_Preserved(t *testing.T) { }, } - canonical, err := createCanonicalCardJSON(cardData) + canonical, err := CreateCanonicalCardJSON(cardData) if err != nil { - t.Fatalf("createCanonicalCardJSON failed: %v", err) + t.Fatalf("CreateCanonicalCardJSON failed: %v", err) } got := string(canonical) @@ -914,16 +879,16 @@ func buildJWSSignatureECDSARawRS(t *testing.T, cardData *agentv1alpha1.AgentCard t.Fatalf("Failed to encode protected header: %v", err) } - payload, err := createCanonicalCardJSON(cardData) + payload, err := CreateCanonicalCardJSON(cardData) if err != nil { t.Fatalf("Failed to create canonical JSON: %v", err) } payloadB64 := base64.RawURLEncoding.EncodeToString(payload) signingInput := []byte(protectedB64 + "." + payloadB64) - hashFunc, err := hashForAlgorithm(alg) + hashFunc, err := HashForAlgorithm(alg) if err != nil { - t.Fatalf("hashForAlgorithm(%s) failed: %v", alg, err) + t.Fatalf("HashForAlgorithm(%s) failed: %v", alg, err) } hasher := hashFunc.New() hasher.Write(signingInput) @@ -935,7 +900,7 @@ func buildJWSSignatureECDSARawRS(t *testing.T, cardData *agentv1alpha1.AgentCard } // Encode as raw R||S (fixed-length, zero-padded) - byteSize := curveByteSize(privKey.Curve) + byteSize := CurveByteSize(privKey.Curve) rBytes := r.Bytes() sBytes := s.Bytes() rawSig := make([]byte, 2*byteSize) @@ -995,7 +960,7 @@ func TestVerifyJWS_RSA_MinKeySize_Rejected(t *testing.T) { // Build a signature with the small key header := &ProtectedHeader{Algorithm: "RS256", Type: "JOSE", KeyID: "small-key"} protectedB64, _ := EncodeProtectedHeader(header) - payload, _ := createCanonicalCardJSON(cardData) + payload, _ := CreateCanonicalCardJSON(cardData) payloadB64 := base64.RawURLEncoding.EncodeToString(payload) signingInput := []byte(protectedB64 + "." + payloadB64) hash := sha256.Sum256(signingInput) @@ -1023,30 +988,6 @@ func TestVerifyJWS_RSA_MinKeySize_Rejected(t *testing.T) { } } -// --- Test: JWKS invalid-curve EC point (#4) --- - -func TestJWKSProvider_InvalidCurvePoint(t *testing.T) { - // ecJWKToPEM should reject off-curve points - provider := &JWKSProvider{} - - // Create a JWK with an off-curve point (invalid X,Y for P-256) - // X=1, Y=1 is not on the P-256 curve - invalidJWK := &JWK{ - Kty: "EC", - Crv: "P-256", - X: base64.RawURLEncoding.EncodeToString([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}), - Y: base64.RawURLEncoding.EncodeToString([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}), - } - - _, err := provider.jwkToPublicKeyPEM(invalidJWK) - if err == nil { - t.Fatal("Expected error for off-curve EC point") - } - if indexOf(err.Error(), "not on curve") == -1 { - t.Errorf("Expected 'not on curve' in error, got: %v", err) - } -} - // --- Unit tests for algorithm helpers --- func TestHashForAlgorithm_AllSupported(t *testing.T) { @@ -1066,12 +1007,12 @@ func TestHashForAlgorithm_AllSupported(t *testing.T) { } for _, tt := range tests { t.Run(tt.alg, func(t *testing.T) { - h, err := hashForAlgorithm(tt.alg) + h, err := HashForAlgorithm(tt.alg) if err != nil { - t.Fatalf("hashForAlgorithm(%s) unexpected error: %v", tt.alg, err) + t.Fatalf("HashForAlgorithm(%s) unexpected error: %v", tt.alg, err) } if h != tt.expected { - t.Errorf("hashForAlgorithm(%s) = %v, want %v", tt.alg, h, tt.expected) + t.Errorf("HashForAlgorithm(%s) = %v, want %v", tt.alg, h, tt.expected) } }) } @@ -1079,9 +1020,9 @@ func TestHashForAlgorithm_AllSupported(t *testing.T) { func TestHashForAlgorithm_Unsupported(t *testing.T) { for _, alg := range []string{"HS256", "none", "", "RS1024"} { - _, err := hashForAlgorithm(alg) + _, err := HashForAlgorithm(alg) if err == nil { - t.Errorf("hashForAlgorithm(%q) expected error, got nil", alg) + t.Errorf("HashForAlgorithm(%q) expected error, got nil", alg) } } } @@ -1173,9 +1114,9 @@ func TestCurveByteSize(t *testing.T) { } for _, tt := range tests { t.Run(tt.curve.Params().Name, func(t *testing.T) { - got := curveByteSize(tt.curve) + got := CurveByteSize(tt.curve) if got != tt.expected { - t.Errorf("curveByteSize(%s) = %d, want %d", tt.curve.Params().Name, got, tt.expected) + t.Errorf("CurveByteSize(%s) = %d, want %d", tt.curve.Params().Name, got, tt.expected) } }) } @@ -1199,28 +1140,6 @@ func TestRemoveEmptyFields_NestedEmptyMapOmitted(t *testing.T) { } } -func TestRemoveEmptyFields_EmptyStringOmitted(t *testing.T) { - input := map[string]interface{}{ - "name": "Agent", - "description": "", - } - result := removeEmptyFields(input) - if _, exists := result["description"]; exists { - t.Error("Expected empty string field to be omitted") - } -} - -func TestRemoveEmptyFields_NilValueOmitted(t *testing.T) { - input := map[string]interface{}{ - "name": "Agent", - "other": nil, - } - result := removeEmptyFields(input) - if _, exists := result["other"]; exists { - t.Error("Expected nil field to be omitted") - } -} - func TestRemoveEmptyFields_EmptySliceOmitted(t *testing.T) { input := map[string]interface{}{ "name": "Agent", @@ -1288,53 +1207,6 @@ func TestRemoveEmptyFields_DeeplyNestedPreservesNonEmpty(t *testing.T) { } } -// --- JWKS: jwkToPublicKeyPEM with unsupported key type --- - -func TestJWKSProvider_UnsupportedKeyType(t *testing.T) { - provider := &JWKSProvider{} - invalidJWK := &JWK{ - Kty: "OKP", // Ed25519 — not supported - Kid: "test-key", - } - _, err := provider.jwkToPublicKeyPEM(invalidJWK) - if err == nil { - t.Fatal("Expected error for unsupported key type 'OKP'") - } - if indexOf(err.Error(), "unsupported") == -1 { - t.Errorf("Expected 'unsupported' in error, got: %v", err) - } -} - -// --- JWKS: RSA JWK with modulus too small --- - -func TestJWKSProvider_RSAKeyTooSmall_JWK(t *testing.T) { - provider := &JWKSProvider{} - - // Generate a 1024-bit RSA key and extract N and E - smallKey, err := rsa.GenerateKey(rand.Reader, 1024) - if err != nil { - t.Fatalf("Failed to generate 1024-bit RSA key: %v", err) - } - - nBytes := smallKey.PublicKey.N.Bytes() - eBytes := big.NewInt(int64(smallKey.PublicKey.E)).Bytes() - - jwk := &JWK{ - Kty: "RSA", - Kid: "small-key", - N: base64.RawURLEncoding.EncodeToString(nBytes), - E: base64.RawURLEncoding.EncodeToString(eBytes), - } - - _, err = provider.jwkToPublicKeyPEM(jwk) - if err == nil { - t.Fatal("Expected error for RSA JWK with 1024-bit modulus") - } - if indexOf(err.Error(), "too small") == -1 && indexOf(err.Error(), "below minimum") == -1 { - t.Errorf("Expected key-size rejection in error, got: %v", err) - } -} - // --- Helper --- func indexOf(s, substr string) int { diff --git a/kagenti-operator/internal/signature/x5c.go b/kagenti-operator/internal/signature/x5c.go new file mode 100644 index 00000000..b03dcbd4 --- /dev/null +++ b/kagenti-operator/internal/signature/x5c.go @@ -0,0 +1,400 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package signature + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "sync" + "time" + + agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" + "github.com/prometheus/client_golang/prometheus" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/metrics" +) + +var x5cLogger = ctrl.Log.WithName("signature").WithName("x5c") + +var ( + x5cChainValidationTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "kagenti_x5c_chain_validation_total", + Help: "Total x5c chain validation attempts", + }, + []string{"valid", "reason"}, + ) + x5cTrustBundleAgeSeconds = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "kagenti_x5c_trust_bundle_age_seconds", + Help: "Age of the cached trust bundle in seconds", + }, + ) + x5cTrustBundleLoadErrorsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "kagenti_x5c_trust_bundle_load_errors_total", + Help: "Trust bundle load/parse failures", + }, + []string{"reason"}, + ) + x5cBindingTrustDomainMismatchTotal = prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "kagenti_x5c_binding_trust_domain_mismatch_total", + Help: "Count of cert SAN SPIFFE ID trust domain mismatches", + }, + ) +) + +func init() { + for _, c := range []prometheus.Collector{ + x5cChainValidationTotal, + x5cTrustBundleAgeSeconds, + x5cTrustBundleLoadErrorsTotal, + x5cBindingTrustDomainMismatchTotal, + } { + if err := metrics.Registry.Register(c); err != nil { + if _, ok := err.(prometheus.AlreadyRegisteredError); !ok { + panic(err) + } + } + } +} + +// X5CProvider verifies JWS signatures via x5c chains against a SPIRE trust bundle +// (ConfigMap in SPIFFE JSON format, from SPIRE's BundlePublisher k8s_configmap plugin). +type X5CProvider struct { + client client.Client + configMapName string + configMapNS string + configMapKey string + refreshInterval time.Duration + + mu sync.RWMutex + trustBundle *x509.CertPool + lastBundleLoad time.Time + bundleHash string // SHA-256 of raw bundle data for change detection +} + +func NewX5CProvider(config *Config) (*X5CProvider, error) { + if config.Client == nil { + return nil, fmt.Errorf("kubernetes client is required for x5c provider") + } + if config.TrustBundleConfigMapName == "" { + return nil, fmt.Errorf("trust bundle configmap name is required for x5c provider") + } + if config.TrustBundleConfigMapNS == "" { + return nil, fmt.Errorf("trust bundle configmap namespace is required for x5c provider") + } + + configMapKey := config.TrustBundleConfigMapKey + if configMapKey == "" { + configMapKey = "bundle.spiffe" + } + refreshInterval := config.TrustBundleRefreshInterval + if refreshInterval == 0 { + refreshInterval = 5 * time.Minute + } + + return &X5CProvider{ + client: config.Client, + configMapName: config.TrustBundleConfigMapName, + configMapNS: config.TrustBundleConfigMapNS, + configMapKey: configMapKey, + refreshInterval: refreshInterval, + }, nil +} + +func (p *X5CProvider) Name() string { return "x5c" } + +func (p *X5CProvider) VerifySignature(ctx context.Context, cardData *agentv1alpha1.AgentCardData, + signatures []agentv1alpha1.AgentCardSignature) (*VerificationResult, error) { + + if err := p.maybeRefreshTrustBundle(ctx); err != nil { + return &VerificationResult{ + Verified: false, + Details: fmt.Sprintf("trust bundle unavailable: %v", err), + }, nil + } + + for i := range signatures { + sig := &signatures[i] + + header, err := DecodeProtectedHeader(sig.Protected) + if err != nil || len(header.X5C) == 0 { + continue + } + + certs, err := parseX5CCerts(header.X5C) + if err != nil || len(certs) == 0 { + x5cChainValidationTotal.WithLabelValues("false", "parse_error").Inc() + continue + } + + leaf := certs[0] + intermediates := certs[1:] + + if err := p.validateChain(leaf, intermediates); err != nil { + x5cChainValidationTotal.WithLabelValues("false", "chain_invalid").Inc() + return &VerificationResult{ + Verified: false, + Details: fmt.Sprintf("x5c chain validation failed: %v", err), + }, nil + } + + spiffeID, err := extractSpiffeIDFromCert(leaf) + if err != nil { + x5cChainValidationTotal.WithLabelValues("false", "san_invalid").Inc() + return &VerificationResult{ + Verified: false, + Details: fmt.Sprintf("leaf certificate SAN validation failed: %v", err), + }, nil + } + + publicKeyPEM, err := MarshalPublicKeyToPEM(leaf.PublicKey) + if err != nil { + continue + } + + result, verifyErr := VerifyJWS(cardData, sig, publicKeyPEM) + if verifyErr == nil && result != nil && result.Verified { + x5cChainValidationTotal.WithLabelValues("true", "ok").Inc() + result.SpiffeID = spiffeID + result.LeafNotAfter = leaf.NotAfter + return result, nil + } + + x5cChainValidationTotal.WithLabelValues("false", "jws_invalid").Inc() + } + + return &VerificationResult{ + Verified: false, + Details: "No signature verified via x5c chain validation", + }, nil +} + +// validateChain verifies the x5c chain against the trust bundle. +// Uses ExtKeyUsageAny because SPIRE SVIDs may lack ServerAuth EKU. +// +// Option B: CurrentTime is pinned to just after the leaf's NotBefore so that +// expired SVID leaf certs still verify as long as the issuing CA remains in +// the trust bundle. The operator handles freshness via proactive workload +// restarts before SVID expiry and on CA rotation. +func (p *X5CProvider) validateChain(leaf *x509.Certificate, intermediates []*x509.Certificate) error { + if len(intermediates)+1 > 3 { + return fmt.Errorf("certificate chain too deep: %d (max 3)", len(intermediates)+1) + } + + intermediatePool := x509.NewCertPool() + for _, cert := range intermediates { + intermediatePool.AddCert(cert) + } + + p.mu.RLock() + roots := p.trustBundle + p.mu.RUnlock() + + opts := x509.VerifyOptions{ + Roots: roots, + Intermediates: intermediatePool, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + CurrentTime: leaf.NotBefore.Add(time.Second), + } + + if _, err := leaf.Verify(opts); err != nil { + return fmt.Errorf("chain verification failed: %w", err) + } + return nil +} + +// extractSpiffeIDFromCert returns the single spiffe:// URI from leaf cert SANs. +func extractSpiffeIDFromCert(leaf *x509.Certificate) (string, error) { + var spiffeIDs []string + for _, uri := range leaf.URIs { + if uri.Scheme == "spiffe" { + spiffeIDs = append(spiffeIDs, uri.String()) + } + } + + if len(spiffeIDs) == 0 { + return "", fmt.Errorf("no spiffe:// URI in leaf certificate SANs (found %d URIs total)", len(leaf.URIs)) + } + if len(spiffeIDs) > 1 { + return "", fmt.Errorf("multiple spiffe:// URIs in leaf certificate SANs (expected exactly 1, found %d)", len(spiffeIDs)) + } + + spiffeID := spiffeIDs[0] + parsed, err := url.Parse(spiffeID) + if err != nil { + return "", fmt.Errorf("malformed SPIFFE ID URI %q: %w", spiffeID, err) + } + if parsed.Host == "" { + return "", fmt.Errorf("SPIFFE ID %q has empty trust domain", spiffeID) + } + + return spiffeID, nil +} + +func parseX5CCerts(x5c []string) ([]*x509.Certificate, error) { + certs := make([]*x509.Certificate, 0, len(x5c)) + for i, b64 := range x5c { + // x5c uses standard base64, not base64url (RFC 7515 §4.1.6) + der, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return nil, fmt.Errorf("x5c[%d]: base64 decode failed: %w", i, err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, fmt.Errorf("x5c[%d]: certificate parse failed: %w", i, err) + } + certs = append(certs, cert) + } + return certs, nil +} + +func (p *X5CProvider) maybeRefreshTrustBundle(ctx context.Context) error { + p.mu.RLock() + needsInitialLoad := p.trustBundle == nil + age := time.Since(p.lastBundleLoad) + p.mu.RUnlock() + + if needsInitialLoad { + if err := p.refreshTrustBundle(ctx); err != nil { + return fmt.Errorf("initial trust bundle load failed: %w", err) + } + return nil + } + + x5cTrustBundleAgeSeconds.Set(age.Seconds()) + + if age < p.refreshInterval { + return nil + } + + if err := p.refreshTrustBundle(ctx); err != nil { + x5cLogger.Error(err, "trust bundle refresh failed, continuing with cached bundle") + x5cTrustBundleLoadErrorsTotal.WithLabelValues("refresh_failed").Inc() + } + return nil +} + +func (p *X5CProvider) BundleHash() string { + p.mu.RLock() + defer p.mu.RUnlock() + return p.bundleHash +} + +// spiffeBundleJSON is the minimal structure of a SPIFFE trust bundle document. +type spiffeBundleJSON struct { + Keys []spiffeBundleKey `json:"keys"` +} + +type spiffeBundleKey struct { + Use string `json:"use"` + X5C []string `json:"x5c"` +} + +func (p *X5CProvider) refreshTrustBundle(ctx context.Context) error { + cm := &corev1.ConfigMap{} + key := types.NamespacedName{Name: p.configMapName, Namespace: p.configMapNS} + if err := p.client.Get(ctx, key, cm); err != nil { + x5cTrustBundleLoadErrorsTotal.WithLabelValues("configmap_not_found").Inc() + return fmt.Errorf("failed to get trust bundle configmap %s/%s: %w", p.configMapNS, p.configMapName, err) + } + + raw, ok := cm.Data[p.configMapKey] + if !ok || raw == "" { + x5cTrustBundleLoadErrorsTotal.WithLabelValues("empty_bundle").Inc() + return fmt.Errorf("trust bundle configmap key %q not found or empty", p.configMapKey) + } + + newHash := hashString(raw) + + var bundle spiffeBundleJSON + if err := json.Unmarshal([]byte(raw), &bundle); err != nil { + x5cTrustBundleLoadErrorsTotal.WithLabelValues("invalid_json").Inc() + return fmt.Errorf("failed to parse SPIFFE bundle JSON: %w", err) + } + + pool := x509.NewCertPool() + count := 0 + for _, k := range bundle.Keys { + if k.Use != "x509-svid" { + continue + } + for _, b64 := range k.X5C { + der, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + x5cTrustBundleLoadErrorsTotal.WithLabelValues("invalid_base64").Inc() + return fmt.Errorf("failed to decode x5c cert from SPIFFE bundle: %w", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + x5cTrustBundleLoadErrorsTotal.WithLabelValues("invalid_cert").Inc() + return fmt.Errorf("failed to parse certificate from SPIFFE bundle: %w", err) + } + pool.AddCert(cert) + count++ + } + } + + if count == 0 { + x5cTrustBundleLoadErrorsTotal.WithLabelValues("empty_bundle").Inc() + return fmt.Errorf("SPIFFE bundle contains no x509-svid certificates") + } + + p.mu.Lock() + oldHash := p.bundleHash + p.trustBundle = pool + p.lastBundleLoad = time.Now() + p.bundleHash = newHash + p.mu.Unlock() + + if oldHash != "" && oldHash != newHash { + x5cLogger.Info("Trust bundle changed (CA rotation detected)", "certificates", count) + } else { + x5cLogger.Info("Trust bundle loaded", "certificates", count) + } + return nil +} + +func hashString(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} + +func IncrementTrustDomainMismatch() { + x5cBindingTrustDomainMismatchTotal.Inc() +} + +// SetTrustBundleForTest injects a trust bundle for unit testing only. +func (p *X5CProvider) SetTrustBundleForTest(pool *x509.CertPool) { + p.mu.Lock() + defer p.mu.Unlock() + p.trustBundle = pool + p.lastBundleLoad = time.Now() + p.refreshInterval = 1 * time.Hour + p.bundleHash = "test" +} diff --git a/kagenti-operator/internal/signature/x5c_test.go b/kagenti-operator/internal/signature/x5c_test.go new file mode 100644 index 00000000..3b161088 --- /dev/null +++ b/kagenti-operator/internal/signature/x5c_test.go @@ -0,0 +1,793 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package signature + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "fmt" + "math/big" + "net/url" + "strings" + "testing" + "time" + + agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" +) + +type testCA struct { + Key *ecdsa.PrivateKey + Cert *x509.Certificate +} + +func newTestCA(t *testing.T) *testCA { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test CA"}, + NotBefore: time.Now().Add(-24 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(certDER) + if err != nil { + t.Fatal(err) + } + return &testCA{Key: key, Cert: cert} +} + +type leafOpts struct { + spiffeIDs []string + notBefore time.Time + notAfter time.Time + extKeyUsage []x509.ExtKeyUsage +} + +func (ca *testCA) issueLeaf(t *testing.T, key crypto.Signer, opts leafOpts) *x509.Certificate { + t.Helper() + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{CommonName: "Test Leaf"}, + NotBefore: opts.notBefore, + NotAfter: opts.notAfter, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: opts.extKeyUsage, + } + if tmpl.NotBefore.IsZero() { + tmpl.NotBefore = time.Now().Add(-1 * time.Hour) + } + if tmpl.NotAfter.IsZero() { + tmpl.NotAfter = time.Now().Add(1 * time.Hour) + } + if tmpl.ExtKeyUsage == nil { + tmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageAny} + } + + for _, id := range opts.spiffeIDs { + u, _ := url.Parse(id) + tmpl.URIs = append(tmpl.URIs, u) + } + + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, ca.Cert, key.Public(), ca.Key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(certDER) + if err != nil { + t.Fatal(err) + } + return cert +} + +func newTestX5CProvider(t *testing.T, ca *testCA) *X5CProvider { + t.Helper() + pool := x509.NewCertPool() + pool.AddCert(ca.Cert) + return &X5CProvider{ + trustBundle: pool, + lastBundleLoad: time.Now(), + refreshInterval: 1 * time.Hour, + bundleHash: "test-hash", + } +} + +func buildTestJWSWithX5C(t *testing.T, cardData *agentv1alpha1.AgentCardData, key crypto.Signer, certs []*x509.Certificate) agentv1alpha1.AgentCardSignature { + t.Helper() + + alg := algForKey(t, key.Public()) + kid := fmt.Sprintf("%x", sha256.Sum256(certs[0].Raw))[:16] + + x5c := make([]string, len(certs)) + for i, cert := range certs { + x5c[i] = base64.StdEncoding.EncodeToString(cert.Raw) + } + + header := &ProtectedHeader{ + Algorithm: alg, + KeyID: kid, + Type: "JOSE", + X5C: x5c, + } + protectedB64, err := EncodeProtectedHeader(header) + if err != nil { + t.Fatal(err) + } + + payload, err := CreateCanonicalCardJSON(cardData) + if err != nil { + t.Fatal(err) + } + payloadB64 := base64.RawURLEncoding.EncodeToString(payload) + signingInput := []byte(protectedB64 + "." + payloadB64) + + hashFunc := crypto.SHA256 + if alg == "ES384" { + hashFunc = crypto.SHA384 + } else if alg == "ES512" { + hashFunc = crypto.SHA512 + } + h := hashFunc.New() + h.Write(signingInput) + hashed := h.Sum(nil) + + var sigBytes []byte + switch k := key.(type) { + case *ecdsa.PrivateKey: + r, s, err := ecdsa.Sign(rand.Reader, k, hashed) + if err != nil { + t.Fatal(err) + } + keySize := (k.Curve.Params().BitSize + 7) / 8 + sigBytes = make([]byte, 2*keySize) + rBytes := r.Bytes() + sBytes := s.Bytes() + copy(sigBytes[keySize-len(rBytes):keySize], rBytes) + copy(sigBytes[2*keySize-len(sBytes):], sBytes) + case *rsa.PrivateKey: + sigBytes, err = rsa.SignPKCS1v15(rand.Reader, k, hashFunc, hashed) + if err != nil { + t.Fatal(err) + } + default: + t.Fatalf("unsupported key type: %T", key) + } + + return agentv1alpha1.AgentCardSignature{ + Protected: protectedB64, + Signature: base64.RawURLEncoding.EncodeToString(sigBytes), + } +} + +func algForKey(t *testing.T, pub crypto.PublicKey) string { + t.Helper() + switch k := pub.(type) { + case *ecdsa.PublicKey: + switch k.Curve { + case elliptic.P256(): + return "ES256" + case elliptic.P384(): + return "ES384" + case elliptic.P521(): + return "ES512" + } + case *rsa.PublicKey: + return "RS256" + } + t.Fatal("unsupported key type") + return "" +} + +func testCard() *agentv1alpha1.AgentCardData { + return &agentv1alpha1.AgentCardData{ + Name: "test-agent", + Version: "1.0.0", + URL: "https://test.example.com/.well-known/agent.json", + } +} + +// Valid x5c chain, valid ECDSA signature, SPIFFE ID extracted from cert SAN +func TestX5CProvider_ValidChainValidSignature(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf := ca.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{"spiffe://example.org/ns/default/sa/test"}, + }) + provider := newTestX5CProvider(t, ca) + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leaf}) + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Verified { + t.Errorf("expected Verified=true, got false: %s", result.Details) + } + if result.SpiffeID != "spiffe://example.org/ns/default/sa/test" { + t.Errorf("expected SpiffeID from cert SAN, got %q", result.SpiffeID) + } +} + +// Cert signed by untrusted CA is rejected +func TestX5CProvider_UnknownCA(t *testing.T) { + ca := newTestCA(t) + otherCA := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf := otherCA.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{"spiffe://example.org/ns/default/sa/test"}, + }) + provider := newTestX5CProvider(t, ca) + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leaf}) + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Verified { + t.Error("expected Verified=false for unknown CA") + } + if !strings.Contains(result.Details, "chain validation failed") { + t.Errorf("expected chain validation failure, got: %s", result.Details) + } +} + +// Option B: expired leaf cert still verifies as long as the CA is in the trust bundle. +// The operator handles freshness via proactive workload restarts. +func TestX5CProvider_ExpiredLeaf_OptionB(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf := ca.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{"spiffe://example.org/ns/default/sa/test"}, + notBefore: time.Now().Add(-2 * time.Hour), + notAfter: time.Now().Add(-1 * time.Hour), + }) + provider := newTestX5CProvider(t, ca) + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leaf}) + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Verified { + t.Errorf("Option B: expected Verified=true for expired leaf (CA still trusted), got: %s", result.Details) + } + if result.LeafNotAfter.IsZero() { + t.Error("expected LeafNotAfter to be set") + } +} + +// No x5c in header falls through to "no signature verified" +func TestX5CProvider_MissingX5C(t *testing.T) { + ca := newTestCA(t) + provider := newTestX5CProvider(t, ca) + card := testCard() + + header := &ProtectedHeader{Algorithm: "ES256", Type: "JOSE"} + protectedB64, _ := EncodeProtectedHeader(header) + sig := agentv1alpha1.AgentCardSignature{ + Protected: protectedB64, + Signature: base64.RawURLEncoding.EncodeToString([]byte("fake")), + } + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Verified { + t.Error("expected Verified=false for missing x5c") + } +} + +// Chain depth > 3 is rejected +func TestX5CProvider_ChainTooDeep(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf := ca.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{"spiffe://example.org/ns/default/sa/test"}, + }) + + extras := make([]*x509.Certificate, 3) + for i := range extras { + k, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + extras[i] = ca.issueLeaf(t, k, leafOpts{ + spiffeIDs: []string{"spiffe://example.org/intermediate"}, + }) + } + + chain := append([]*x509.Certificate{leaf}, extras...) + provider := newTestX5CProvider(t, ca) + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, chain) + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Verified { + t.Error("expected Verified=false for chain too deep") + } + if !strings.Contains(result.Details, "chain too deep") { + t.Errorf("expected 'chain too deep' in details, got: %s", result.Details) + } +} + +// Tampered payload detected by signature verification +func TestX5CProvider_TamperedPayload(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf := ca.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{"spiffe://example.org/ns/default/sa/test"}, + }) + provider := newTestX5CProvider(t, ca) + + originalCard := testCard() + sig := buildTestJWSWithX5C(t, originalCard, key, []*x509.Certificate{leaf}) + + tamperedCard := testCard() + tamperedCard.Name = "tampered-agent" + + result, err := provider.VerifySignature(context.Background(), tamperedCard, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Verified { + t.Error("expected Verified=false for tampered payload") + } +} + +// Tampered protected header detected by signature verification +func TestX5CProvider_TamperedHeader(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf := ca.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{"spiffe://example.org/ns/default/sa/test"}, + }) + provider := newTestX5CProvider(t, ca) + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leaf}) + + header, _ := DecodeProtectedHeader(sig.Protected) + header.KeyID = "tampered-kid" + tamperedProtected, _ := EncodeProtectedHeader(header) + sig.Protected = tamperedProtected + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Verified { + t.Error("expected Verified=false for tampered header") + } +} + +// Leaf cert with no SAN URIs is rejected +func TestX5CProvider_NoSANURIs(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf := ca.issueLeaf(t, key, leafOpts{}) + provider := newTestX5CProvider(t, ca) + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leaf}) + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Verified { + t.Error("expected Verified=false for cert with no SAN URIs") + } + if !strings.Contains(result.Details, "no spiffe:// URI") { + t.Errorf("expected SAN validation failure, got: %s", result.Details) + } +} + +// Multiple spiffe:// URIs in leaf cert is rejected (exactly one required) +func TestX5CProvider_MultipleSpiffeURIs(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf := ca.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{ + "spiffe://example.org/ns/default/sa/test1", + "spiffe://example.org/ns/default/sa/test2", + }, + }) + provider := newTestX5CProvider(t, ca) + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leaf}) + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Verified { + t.Error("expected Verified=false for multiple spiffe:// URIs") + } + if !strings.Contains(result.Details, "multiple spiffe:// URIs") { + t.Errorf("expected multiple URIs failure, got: %s", result.Details) + } +} + +// Empty trust domain in SPIFFE ID is rejected +func TestX5CProvider_EmptyTrustDomain(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf := ca.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{"spiffe:///workload"}, + }) + provider := newTestX5CProvider(t, ca) + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leaf}) + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Verified { + t.Error("expected Verified=false for empty trust domain") + } +} + +// Trust bundle hot-swap: old CA fails, new CA succeeds +func TestX5CProvider_TrustBundleRotation(t *testing.T) { + oldCA := newTestCA(t) + newCA := newTestCA(t) + + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf := newCA.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{"spiffe://example.org/ns/default/sa/test"}, + }) + + provider := newTestX5CProvider(t, oldCA) + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leaf}) + + result, _ := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if result.Verified { + t.Error("expected Verified=false with old trust bundle") + } + + newPool := x509.NewCertPool() + newPool.AddCert(newCA.Cert) + provider.mu.Lock() + provider.trustBundle = newPool + provider.mu.Unlock() + + result, _ = provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if !result.Verified { + t.Errorf("expected Verified=true after trust bundle rotation, got: %s", result.Details) + } +} + +// --- Golden vector tests: signer output verified by both X5CProvider and raw VerifyJWS --- + +func TestGoldenVector_SignerToX5CProvider(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf := ca.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{"spiffe://example.org/ns/default/sa/golden"}, + }) + + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leaf, ca.Cert}) + + provider := newTestX5CProvider(t, ca) + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Verified { + t.Errorf("golden vector: X5CProvider rejected: %s", result.Details) + } + + pubPEM, _ := MarshalPublicKeyToPEM(&key.PublicKey) + rawResult, err := VerifyJWS(card, &sig, pubPEM) + if err != nil { + t.Fatalf("VerifyJWS error: %v", err) + } + if !rawResult.Verified { + t.Errorf("golden vector: raw VerifyJWS rejected: %s", rawResult.Details) + } +} + +func TestGoldenVector_RSA_SignerToX5CProvider(t *testing.T) { + ca := newTestCA(t) + key, _ := rsa.GenerateKey(rand.Reader, 2048) + leaf := ca.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{"spiffe://example.org/ns/default/sa/rsa-golden"}, + }) + + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leaf, ca.Cert}) + + provider := newTestX5CProvider(t, ca) + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Verified { + t.Errorf("RSA golden vector: X5CProvider rejected: %s", result.Details) + } + + pubPEM, _ := MarshalPublicKeyToPEM(&key.PublicKey) + rawResult, err := VerifyJWS(card, &sig, pubPEM) + if err != nil { + t.Fatalf("VerifyJWS error: %v", err) + } + if !rawResult.Verified { + t.Errorf("RSA golden vector: raw VerifyJWS rejected: %s", rawResult.Details) + } +} + +// --- Trust bundle rotation: old CA removed causes old signatures to fail --- + +func TestX5CProvider_TrustBundleRotation_OldCARemoved(t *testing.T) { + oldCA := newTestCA(t) + newCA := newTestCA(t) + + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leafSignedByOldCA := oldCA.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{"spiffe://example.org/ns/default/sa/test"}, + }) + + // Start with both CAs trusted + bothPool := x509.NewCertPool() + bothPool.AddCert(oldCA.Cert) + bothPool.AddCert(newCA.Cert) + provider := &X5CProvider{ + trustBundle: bothPool, + lastBundleLoad: time.Now(), + refreshInterval: 1 * time.Hour, + } + + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leafSignedByOldCA}) + + result, _ := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if !result.Verified { + t.Fatalf("expected Verified=true with both CAs trusted, got: %s", result.Details) + } + + // Rotate: remove old CA, keep only new CA + newOnlyPool := x509.NewCertPool() + newOnlyPool.AddCert(newCA.Cert) + provider.mu.Lock() + provider.trustBundle = newOnlyPool + provider.mu.Unlock() + + result, _ = provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if result.Verified { + t.Error("expected Verified=false after old CA removed from trust bundle") + } + if !strings.Contains(result.Details, "chain validation failed") { + t.Errorf("expected chain validation failure, got: %s", result.Details) + } +} + +// --- Additional negative/security tests --- + +func TestX5CProvider_TamperedSignatureBytes(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf := ca.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{"spiffe://example.org/ns/default/sa/test"}, + }) + provider := newTestX5CProvider(t, ca) + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leaf}) + + sigBytes, _ := base64.RawURLEncoding.DecodeString(sig.Signature) + sigBytes[0] ^= 0xFF // flip first byte + sig.Signature = base64.RawURLEncoding.EncodeToString(sigBytes) + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Verified { + t.Error("expected Verified=false for tampered signature bytes") + } +} + +func TestX5CProvider_InvalidX5CBase64(t *testing.T) { + ca := newTestCA(t) + provider := newTestX5CProvider(t, ca) + card := testCard() + + header := &ProtectedHeader{ + Algorithm: "ES256", + Type: "JOSE", + X5C: []string{"not-valid-base64!!!"}, + } + protectedB64, _ := EncodeProtectedHeader(header) + sig := agentv1alpha1.AgentCardSignature{ + Protected: protectedB64, + Signature: base64.RawURLEncoding.EncodeToString([]byte("fake")), + } + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Verified { + t.Error("expected Verified=false for invalid x5c base64") + } +} + +func TestX5CProvider_NonSpiffeURI(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{CommonName: "Test Leaf"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(1 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + } + u, _ := url.Parse("https://not-spiffe.example.com/identity") + tmpl.URIs = append(tmpl.URIs, u) + + certDER, _ := x509.CreateCertificate(rand.Reader, tmpl, ca.Cert, &key.PublicKey, ca.Key) + leaf, _ := x509.ParseCertificate(certDER) + + provider := newTestX5CProvider(t, ca) + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leaf}) + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Verified { + t.Error("expected Verified=false for non-spiffe URI in cert SAN") + } + if !strings.Contains(result.Details, "no spiffe:// URI") { + t.Errorf("expected 'no spiffe:// URI' in details, got: %s", result.Details) + } +} + +// Option B: not-yet-valid leaf still verifies because time validation is skipped. +// The init-container only signs with certs it just received from SPIRE, so a +// not-yet-valid cert in practice means clock skew, which Option B tolerates. +func TestX5CProvider_NotYetValidLeaf_OptionB(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + leaf := ca.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{"spiffe://example.org/ns/default/sa/test"}, + notBefore: time.Now().Add(1 * time.Hour), + notAfter: time.Now().Add(2 * time.Hour), + }) + provider := newTestX5CProvider(t, ca) + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leaf}) + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Verified { + t.Errorf("Option B: expected Verified=true for not-yet-valid leaf (CA trusted), got: %s", result.Details) + } +} + +func TestX5CProvider_EmptySignatures(t *testing.T) { + ca := newTestCA(t) + provider := newTestX5CProvider(t, ca) + card := testCard() + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Verified { + t.Error("expected Verified=false for empty signatures slice") + } +} + +// BundleHash returns a stable hash and changes when the bundle changes +func TestX5CProvider_BundleHash(t *testing.T) { + ca1 := newTestCA(t) + ca2 := newTestCA(t) + + provider := newTestX5CProvider(t, ca1) + hash1 := provider.BundleHash() + if hash1 == "" { + t.Error("expected non-empty bundle hash") + } + + pool2 := x509.NewCertPool() + pool2.AddCert(ca2.Cert) + provider.mu.Lock() + provider.trustBundle = pool2 + provider.bundleHash = "different" + provider.mu.Unlock() + + hash2 := provider.BundleHash() + if hash1 == hash2 { + t.Error("expected different hash after bundle change") + } +} + +// LeafNotAfter is populated in verification result +func TestX5CProvider_LeafNotAfter(t *testing.T) { + ca := newTestCA(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + expiry := time.Now().Add(4 * time.Hour).Truncate(time.Second) + leaf := ca.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{"spiffe://example.org/ns/default/sa/test"}, + notBefore: time.Now().Add(-1 * time.Minute), + notAfter: expiry, + }) + provider := newTestX5CProvider(t, ca) + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leaf}) + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Verified { + t.Fatalf("expected Verified=true, got: %s", result.Details) + } + if !result.LeafNotAfter.Truncate(time.Second).Equal(expiry) { + t.Errorf("expected LeafNotAfter=%v, got %v", expiry, result.LeafNotAfter) + } +} + +// RSA key path works end-to-end +func TestX5CProvider_RSA_CrossValidation(t *testing.T) { + ca := newTestCA(t) + key, _ := rsa.GenerateKey(rand.Reader, 2048) + leaf := ca.issueLeaf(t, key, leafOpts{ + spiffeIDs: []string{"spiffe://example.org/ns/default/sa/rsa-agent"}, + }) + provider := newTestX5CProvider(t, ca) + card := testCard() + sig := buildTestJWSWithX5C(t, card, key, []*x509.Certificate{leaf}) + + result, err := provider.VerifySignature(context.Background(), card, []agentv1alpha1.AgentCardSignature{sig}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Verified { + t.Errorf("RSA cross-validation failed: %s", result.Details) + } + if result.SpiffeID != "spiffe://example.org/ns/default/sa/rsa-agent" { + t.Errorf("SpiffeID mismatch: %s", result.SpiffeID) + } +} diff --git a/kagenti-operator/internal/webhook/v1alpha1/agentcard_webhook.go b/kagenti-operator/internal/webhook/v1alpha1/agentcard_webhook.go index 66e6dc42..bb709e5e 100644 --- a/kagenti-operator/internal/webhook/v1alpha1/agentcard_webhook.go +++ b/kagenti-operator/internal/webhook/v1alpha1/agentcard_webhook.go @@ -28,7 +28,6 @@ import ( var agentcardlog = ctrl.Log.WithName("agentcard-webhook") -// SetupAgentCardWebhookWithManager will setup the manager to manage the webhooks func SetupAgentCardWebhookWithManager(mgr ctrl.Manager) error { return ctrl.NewWebhookManagedBy(mgr). For(&agentv1alpha1.AgentCard{}). @@ -38,10 +37,8 @@ func SetupAgentCardWebhookWithManager(mgr ctrl.Manager) error { //+kubebuilder:webhook:path=/validate-agent-kagenti-dev-v1alpha1-agentcard,mutating=false,failurePolicy=fail,sideEffects=None,groups=agent.kagenti.dev,resources=agentcards,verbs=create;update,versions=v1alpha1,name=vagentcard.kb.io,admissionReviewVersions=v1 -// AgentCardValidator implements validating webhook for AgentCard type AgentCardValidator struct{} -// ValidateCreate implements webhook validation for create func (v *AgentCardValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { agentcard, ok := obj.(*agentv1alpha1.AgentCard) if !ok { @@ -53,7 +50,6 @@ func (v *AgentCardValidator) ValidateCreate(ctx context.Context, obj runtime.Obj return v.validateAgentCard(agentcard) } -// ValidateUpdate implements webhook validation for update func (v *AgentCardValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { agentcard, ok := newObj.(*agentv1alpha1.AgentCard) if !ok { @@ -65,7 +61,6 @@ func (v *AgentCardValidator) ValidateUpdate(ctx context.Context, oldObj, newObj return v.validateAgentCard(agentcard) } -// ValidateDelete implements webhook validation for delete func (v *AgentCardValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { agentcard, ok := obj.(*agentv1alpha1.AgentCard) if !ok { @@ -74,11 +69,9 @@ func (v *AgentCardValidator) ValidateDelete(ctx context.Context, obj runtime.Obj agentcardlog.Info("validate delete", "name", agentcard.Name) - // Allow deletions return nil, nil } -// validateAgentCard validates the AgentCard spec func (v *AgentCardValidator) validateAgentCard(agentcard *agentv1alpha1.AgentCard) (admission.Warnings, error) { var warnings admission.Warnings @@ -87,8 +80,5 @@ func (v *AgentCardValidator) validateAgentCard(agentcard *agentv1alpha1.AgentCar return nil, fmt.Errorf("spec.targetRef is required: specify the workload backing this agent") } - // Field-level validation for targetRef (e.g., non-empty APIVersion/Kind/Name) - // is enforced by the CRD schema (minLength constraints), so it is not repeated here. - return warnings, nil } diff --git a/kagenti-operator/scripts/generate-keys.sh b/kagenti-operator/scripts/generate-keys.sh deleted file mode 100755 index a7253dcc..00000000 --- a/kagenti-operator/scripts/generate-keys.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash -# Generate RSA key pair for A2A AgentCard signing - -set -e - -KEY_ID="${1:-default}" -OUTPUT_DIR="${2:-.}" - -echo "Generating RSA key pair for A2A AgentCard signing..." -echo "Key ID: $KEY_ID" -echo "Output directory: $OUTPUT_DIR" - -# Create output directory if it doesn't exist -mkdir -p "$OUTPUT_DIR" - -PRIVATE_KEY="$OUTPUT_DIR/private-key-${KEY_ID}.pem" -PUBLIC_KEY="$OUTPUT_DIR/public-key-${KEY_ID}.pem" - -# Generate private key -echo "Generating private key..." -openssl genrsa -out "$PRIVATE_KEY" 2048 - -# Extract public key -echo "Extracting public key..." -openssl rsa -in "$PRIVATE_KEY" -pubout -out "$PUBLIC_KEY" - -echo "" -echo "✓ Key pair generated successfully!" -echo "" -echo "Private key: $PRIVATE_KEY" -echo "Public key: $PUBLIC_KEY" -echo "" -echo "⚠️ IMPORTANT: Keep the private key secure and never commit it to version control!" -echo "" -echo "Next steps:" -echo "1. Create a Kubernetes Secret with the public key:" -echo " kubectl create secret generic a2a-public-keys \\" -echo " --from-file=${KEY_ID}.pem=$PUBLIC_KEY \\" -echo " --namespace=kagenti-system" -echo "" -echo "2. Sign your agent cards with the private key:" -echo " python scripts/sign-agent-card.py agent-card.json $PRIVATE_KEY --key-id $KEY_ID" -echo "" - - diff --git a/kagenti-operator/scripts/replace-operator.sh b/kagenti-operator/scripts/replace-operator.sh deleted file mode 100755 index 7551a8e1..00000000 --- a/kagenti-operator/scripts/replace-operator.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -set -e -TAG=$(date +%Y%m%d%H%M%S) - -docker build . --tag local/kagenti-operator:${TAG} --load -kind load docker-image --name kagenti local/kagenti-operator:${TAG} -kubectl -n kagenti-system set image deployment/kagenti-controller-manager manager=local/kagenti-operator:${TAG} - -# Patch the command to use /manager instead of /ko-app/cmd -kubectl -n kagenti-system patch deployment kagenti-controller-manager --type='json' -p='[ - { - "op": "replace", - "path": "/spec/template/spec/containers/0/command/0", - "value": "/manager" - } -]' - -kubectl rollout status -n kagenti-system deployment/kagenti-controller-manager -kubectl get -n kagenti-system pod -l app.kubernetes.io/name=kagenti-operator-chart diff --git a/kagenti-operator/scripts/sign-agent-card.py b/kagenti-operator/scripts/sign-agent-card.py deleted file mode 100644 index a97f3a8d..00000000 --- a/kagenti-operator/scripts/sign-agent-card.py +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env python3 -""" -Sign an A2A AgentCard using JWS Compact Serialization. - -Produces signatures conforming to A2A spec section 8.4.2: - - Protected Header: {"alg": "RS256", "kid": "", "spiffe_id": "..."} - - Payload: canonical JSON of the card (sorted keys, no whitespace, excluding "signatures") - - Signature: BASE64URL(RSA-SHA256(signingInput)) - -Usage: - python sign-agent-card.py --key-id KEY_ID [--spiffe-id SPIFFE_ID] - -Example: - python sign-agent-card.py weather-agent-card.json private-key.pem --key-id my-key - python sign-agent-card.py weather-agent-card.json private-key.pem --key-id my-key --spiffe-id spiffe://cluster.local/ns/default/sa/weather -""" - -import sys -import json -import base64 -import argparse - -try: - from cryptography.hazmat.primitives import hashes, serialization - from cryptography.hazmat.primitives.asymmetric import padding, rsa, ec - from cryptography.hazmat.backends import default_backend -except ImportError: - print("Error: cryptography library not found.") - print("Install it with: pip install cryptography") - sys.exit(1) - - -def load_private_key(key_path): - """Load a private key from an unencrypted PEM file. - - Note: Only unencrypted PEM keys are supported. For encrypted keys, decrypt - first with: openssl rsa -in encrypted.pem -out decrypted.pem - In production, store signing keys in a vault (e.g. HashiCorp Vault, AWS KMS) - and use a dedicated signing service. - """ - try: - with open(key_path, 'rb') as f: - private_key = serialization.load_pem_private_key( - f.read(), - password=None, - backend=default_backend() - ) - return private_key - except Exception as e: - print(f"Error loading private key: {e}") - sys.exit(1) - - -def base64url_encode(data: bytes) -> str: - """Encode bytes to base64url without padding (per RFC 7515).""" - return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii') - - -def _strip_empty(obj): - """Recursively remove None, empty strings, empty lists, and empty dicts. - - Mirrors the Go removeEmptyFields helper so that nested structures (e.g. - capabilities, skills) are cleaned identically on both sides. Boolean - False and numeric 0 are preserved intentionally. - """ - if isinstance(obj, dict): - cleaned = {} - for k, v in obj.items(): - v = _strip_empty(v) - if v is None or v == "" or v == [] or v == {}: - continue - cleaned[k] = v - return cleaned - if isinstance(obj, list): - return [_strip_empty(item) for item in obj] - return obj - - -def create_canonical_json(card_data): - """ - Create canonical JSON payload for JWS signing. - - Per A2A spec, the payload is the card's JSON with: - - "signatures" field excluded - - Keys sorted alphabetically - - No whitespace (compact separators) - - Empty/None values recursively stripped (matches Go removeEmptyFields) - """ - card_copy = dict(card_data) - card_copy.pop('signatures', None) - card_copy.pop('signature', None) # Legacy field, just in case - - # Recursively remove empty/None values to match Go canonical JSON behavior - card_copy = _strip_empty(card_copy) - - canonical = json.dumps(card_copy, sort_keys=True, separators=(',', ':')) - return canonical.encode('utf-8') - - -def build_protected_header(algorithm, key_id, spiffe_id=None): - """Build the JWS Protected Header as a base64url-encoded string. - - Per A2A spec §8.4.2, the protected header MUST include: alg, typ, kid. - """ - header = {"alg": algorithm, "kid": key_id, "typ": "JOSE"} - if spiffe_id: - header["spiffe_id"] = spiffe_id - header_json = json.dumps(header, sort_keys=True, separators=(',', ':')) - return base64url_encode(header_json.encode('utf-8')) - - -def sign_card_jws(card_data, private_key, key_id, spiffe_id=None): - """ - Sign an agent card in JWS Compact Serialization format. - - Returns the updated card_data with a "signatures" array containing one entry: - {"protected": "", "signature": ""} - """ - # Determine algorithm from key type - if isinstance(private_key, rsa.RSAPrivateKey): - algorithm = 'RS256' - elif isinstance(private_key, ec.EllipticCurvePrivateKey): - curve_name = private_key.curve.name - if curve_name == 'secp256r1': - algorithm = 'ES256' - elif curve_name == 'secp384r1': - algorithm = 'ES384' - elif curve_name == 'secp521r1': - algorithm = 'ES512' - else: - print(f"Error: Unsupported EC curve: {curve_name}") - sys.exit(1) - else: - print("Error: Unsupported key type. Only RSA and ECDSA keys are supported.") - sys.exit(1) - - # Build protected header (base64url) - protected_b64 = build_protected_header(algorithm, key_id, spiffe_id) - - # Build canonical payload (base64url) - canonical_payload = create_canonical_json(card_data) - payload_b64 = base64url_encode(canonical_payload) - - # Construct signing input: BASE64URL(header) || '.' || BASE64URL(payload) - signing_input = f"{protected_b64}.{payload_b64}".encode('ascii') - - # Sign - if algorithm == 'RS256': - signature_bytes = private_key.sign( - signing_input, - padding.PKCS1v15(), - hashes.SHA256() - ) - elif algorithm.startswith('ES'): - # Note: Python's cryptography library produces DER-encoded (ASN.1) ECDSA - # signatures, whereas the JWS spec (RFC 7518 §3.4) requires raw R||S format. - # The Go verifier tries raw R||S first, then falls back to DER, so both - # formats are accepted. If strict JWS compliance is needed, convert to - # raw R||S here using cryptography.hazmat.primitives.asymmetric.utils.decode_dss_signature. - hash_algo = { - 'ES256': hashes.SHA256(), - 'ES384': hashes.SHA384(), - 'ES512': hashes.SHA512(), - }[algorithm] - signature_bytes = private_key.sign( - signing_input, - ec.ECDSA(hash_algo) - ) - else: - print(f"Error: Unsupported algorithm: {algorithm}") - sys.exit(1) - - signature_b64 = base64url_encode(signature_bytes) - - # Build signatures array (A2A spec allows multiple signatures) - sig_entry = { - "protected": protected_b64, - "signature": signature_b64, - } - - # Add to card (replace any existing signatures) - card_data["signatures"] = [sig_entry] - - return card_data, algorithm - - -def main(): - parser = argparse.ArgumentParser( - description='Sign an A2A AgentCard using JWS Compact Serialization (A2A spec section 8.4.2)' - ) - parser.add_argument( - 'card_file', - help='Path to the agent card JSON file' - ) - parser.add_argument( - 'key_file', - help='Path to the private key PEM file (must be unencrypted; see load_private_key docstring)' - ) - parser.add_argument( - '--key-id', - required=True, - help='Key ID to include in the JWS protected header (kid)' - ) - parser.add_argument( - '--spiffe-id', - help='SPIFFE ID to include in the JWS protected header (spiffe_id)' - ) - parser.add_argument( - '--output', - help='Output file (default: overwrite input file)' - ) - - args = parser.parse_args() - - # Load agent card - try: - with open(args.card_file, 'r') as f: - card_data = json.load(f) - except Exception as e: - print(f"Error loading agent card: {e}") - sys.exit(1) - - # Load private key - private_key = load_private_key(args.key_file) - - # Sign the card in JWS format - signed_card, algorithm = sign_card_jws(card_data, private_key, args.key_id, args.spiffe_id) - - # Write output - output_file = args.output or args.card_file - try: - with open(output_file, 'w') as f: - json.dump(signed_card, f, indent=2) - print(f"Successfully signed agent card (JWS format) → {output_file}") - print(f" Algorithm: {algorithm}") - print(f" Key ID: {args.key_id}") - if args.spiffe_id: - print(f" SPIFFE ID: {args.spiffe_id}") - print(f" Signatures: {len(signed_card['signatures'])} entry") - except Exception as e: - print(f"Error writing signed card: {e}") - sys.exit(1) - - -if __name__ == '__main__': - main() diff --git a/kagenti-operator/test/integration/identity_binding_integration_test.go b/kagenti-operator/test/integration/identity_binding_integration_test.go index 9d7fe51e..2e975233 100644 --- a/kagenti-operator/test/integration/identity_binding_integration_test.go +++ b/kagenti-operator/test/integration/identity_binding_integration_test.go @@ -149,7 +149,7 @@ func testMatchingBindingEvaluation(t *testing.T) { // Create AgentCard with matching SPIFFE ID using targetRef expectedSpiffeID := fmt.Sprintf("spiffe://%s/ns/%s/sa/%s", trustDomain, testNamespace, saName) - agentCard := createTestAgentCard(t, ctx, cardName, deploymentName, []agentv1alpha1.SpiffeID{agentv1alpha1.SpiffeID(expectedSpiffeID)}, false) + agentCard := createTestAgentCard(t, ctx, cardName, deploymentName, false) defer deleteResource(ctx, agentCard) // Create and run AgentCard reconciler with a mock signature provider that @@ -221,8 +221,7 @@ func testNonMatchingBindingEvaluation(t *testing.T) { defer deleteResource(ctx, service) // Create AgentCard with NON-matching SPIFFE ID in allowlist using targetRef - wrongSpiffeID := fmt.Sprintf("spiffe://%s/ns/other/sa/other-sa", trustDomain) - agentCard := createTestAgentCard(t, ctx, cardName, deploymentName, []agentv1alpha1.SpiffeID{agentv1alpha1.SpiffeID(wrongSpiffeID)}, false) + agentCard := createTestAgentCard(t, ctx, cardName, deploymentName, false) defer deleteResource(ctx, agentCard) // The workload's actual SPIFFE ID (from mock provider) does NOT match the allowlist @@ -269,7 +268,7 @@ func testNonMatchingBindingEvaluation(t *testing.T) { t.Logf("✓ Binding Status: Bound=%v", card.Status.BindingStatus.Bound) t.Logf("✓ Reason: %s", card.Status.BindingStatus.Reason) t.Logf("✓ Expected SPIFFE ID: %s", card.Status.ExpectedSpiffeID) - t.Logf("✓ Allowed SPIFFE IDs: %v", agentCard.Spec.IdentityBinding.AllowedSpiffeIDs) + t.Logf("✓ Trust Domain: %s", agentCard.Spec.IdentityBinding.TrustDomain) t.Log("✓ TEST 2 PASSED: Non-matching binding evaluated as NotBound") } @@ -298,7 +297,7 @@ func createTestService(t *testing.T, ctx context.Context, name string) *corev1.S return service } -func createTestAgentCard(t *testing.T, ctx context.Context, name, deploymentName string, allowedIDs []agentv1alpha1.SpiffeID, strict bool) *agentv1alpha1.AgentCard { +func createTestAgentCard(t *testing.T, ctx context.Context, name, deploymentName string, strict bool) *agentv1alpha1.AgentCard { agentCard := &agentv1alpha1.AgentCard{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -312,8 +311,8 @@ func createTestAgentCard(t *testing.T, ctx context.Context, name, deploymentName Name: deploymentName, }, IdentityBinding: &agentv1alpha1.IdentityBinding{ - AllowedSpiffeIDs: allowedIDs, - Strict: strict, + TrustDomain: trustDomain, + Strict: strict, }, }, }