From 65391845447eff1671602d8293022ed9bccb9876 Mon Sep 17 00:00:00 2001 From: Kevin Cogan Date: Tue, 10 Feb 2026 12:00:17 +0000 Subject: [PATCH] feat: A2A agent card signature verification Integrated on top of upstream/main (identity-binding, workload-based architecture). New features: - Signature verification with Secret and JWKS providers - Canonical JSON hashing (struct-to-map approach, future-proof) - ECDSA (P-256/384/521) + RSA support for both Secret and JWKS - Audit mode for gradual rollout - Prometheus metrics for verification outcomes - NetworkPolicy enforcement based on signature status - SignatureIdentityMatch: combined signature + identity binding check - Helm values for signature verification configuration Architecture alignment: - Signature verification works with targetRef (duck-typed workloads) - NetworkPolicy controller adapted to Deployment/StatefulSet architecture - All new status fields added to AgentCardStatus - Updated RBAC for secrets and networkpolicies - Updated CRDs and deepcopy generated Signed-off-by: Kevin Cogan --- .../crds/agent.kagenti.dev_agentcards.yaml | 150 ++- .../templates/manager/manager.yaml | 22 + charts/kagenti-operator/values.yaml | 19 + .../api/v1alpha1/agentcard_types.go | 84 +- .../api/v1alpha1/zz_generated.deepcopy.go | 47 + kagenti-operator/cmd/main.go | 75 +- .../bases/agent.kagenti.dev_agentcards.yaml | 88 +- kagenti-operator/config/rbac/role.yaml | 16 + .../agent-with-signature.yaml | 34 + .../signature-verification/kustomization.yaml | 10 + .../secret-with-public-key.yaml | 27 + .../docs/a2a-signature-verification.md | 804 +++++++++++ .../docs/identity-binding-quickstart.md | 594 +++++---- kagenti-operator/docs/jwks_guide.md | 432 ++++++ .../internal/controller/agent_controller.go | 18 +- .../controller/agentcard_controller.go | 468 +++++-- .../agentcard_networkpolicy_controller.go | 577 ++++++++ .../controller/agentcardsync_controller.go | 110 ++ .../agentcardsync_controller_test.go | 25 +- .../controller/identity_binding_test.go | 276 ++-- .../controller/signature_verification_test.go | 1182 +++++++++++++++++ kagenti-operator/internal/signature/jwks.go | 351 +++++ .../internal/signature/metrics.go | 80 ++ kagenti-operator/internal/signature/noop.go | 45 + .../internal/signature/provider.go | 91 ++ .../internal/signature/provider_test.go | 179 +++ kagenti-operator/internal/signature/secret.go | 216 +++ .../internal/signature/secret_test.go | 428 ++++++ .../internal/signature/verifier.go | 504 +++++++ .../internal/signature/verifier_test.go | 881 ++++++++++++ kagenti-operator/scripts/generate-keys.sh | 45 + kagenti-operator/scripts/sign-agent-card.py | 214 +++ .../identity_binding_integration_test.go | 61 +- 33 files changed, 7553 insertions(+), 600 deletions(-) create mode 100644 kagenti-operator/config/samples/signature-verification/agent-with-signature.yaml create mode 100644 kagenti-operator/config/samples/signature-verification/kustomization.yaml create mode 100644 kagenti-operator/config/samples/signature-verification/secret-with-public-key.yaml create mode 100644 kagenti-operator/docs/a2a-signature-verification.md create mode 100644 kagenti-operator/docs/jwks_guide.md create mode 100644 kagenti-operator/internal/controller/agentcard_networkpolicy_controller.go create mode 100644 kagenti-operator/internal/controller/signature_verification_test.go create mode 100644 kagenti-operator/internal/signature/jwks.go create mode 100644 kagenti-operator/internal/signature/metrics.go create mode 100644 kagenti-operator/internal/signature/noop.go create mode 100644 kagenti-operator/internal/signature/provider.go create mode 100644 kagenti-operator/internal/signature/provider_test.go create mode 100644 kagenti-operator/internal/signature/secret.go create mode 100644 kagenti-operator/internal/signature/secret_test.go create mode 100644 kagenti-operator/internal/signature/verifier.go create mode 100644 kagenti-operator/internal/signature/verifier_test.go create mode 100755 kagenti-operator/scripts/generate-keys.sh create mode 100644 kagenti-operator/scripts/sign-agent-card.py diff --git a/charts/kagenti-operator/crds/agent.kagenti.dev_agentcards.yaml b/charts/kagenti-operator/crds/agent.kagenti.dev_agentcards.yaml index 831b3dab..a47184bc 100644 --- a/charts/kagenti-operator/crds/agent.kagenti.dev_agentcards.yaml +++ b/charts/kagenti-operator/crds/agent.kagenti.dev_agentcards.yaml @@ -22,10 +22,22 @@ spec: jsonPath: .status.protocol name: Protocol type: string + - description: Workload Kind + jsonPath: .status.targetRef.kind + name: Kind + type: string + - description: Target Workload + jsonPath: .status.targetRef.name + name: Target + type: string - description: Agent Name jsonPath: .status.card.name name: Agent type: string + - description: Signature Verified + jsonPath: .status.validSignature + name: Verified + type: boolean - description: Identity Bound jsonPath: .status.bindingStatus.bound name: Bound @@ -72,7 +84,8 @@ spec: 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:/// + 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:/// @@ -80,23 +93,39 @@ spec: 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. When true and binding fails, - the Agent controller will scale the deployment to 0. + 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. type: boolean trustDomain: description: |- - TrustDomain overrides the controller's default trust domain. - Must be a valid DNS-like string without slashes. + 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. 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 + 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. properties: matchLabels: additionalProperties: @@ -112,8 +141,31 @@ spec: description: SyncPeriod is how often to re-fetch the agent card (e.g., "30s", "5m") type: string - required: - - selector + 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. + properties: + apiVersion: + description: APIVersion is the API version of the target resource + (e.g., "apps/v1") + minLength: 1 + type: string + kind: + description: Kind is the kind of the target resource (e.g., "Deployment", + "StatefulSet") + minLength: 1 + type: string + name: + description: Name is the name of the target resource + minLength: 1 + type: string + required: + - apiVersion + - kind + - name + type: object type: object status: description: AgentCardStatus defines the observed state of AgentCard. @@ -175,6 +227,40 @@ spec: name: 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). + items: + description: |- + AgentCardSignature represents a JWS signature on an AgentCard. + Follows the A2A specification section 8.4.2 — JWS JSON Serialization. + properties: + header: + description: Header contains optional unprotected JWS header + parameters. + properties: + timestamp: + description: Timestamp is when the signature was created + (ISO 8601 string) + 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://..."}. + type: string + signature: + description: |- + Signature is the base64url-encoded JWS signature value. + The signing input is: BASE64URL(protected) || '.' || BASE64URL(canonical_payload) + type: string + required: + - protected + - signature + type: object + type: array skills: description: Skills is a list of skills/capabilities this agent offers @@ -303,8 +389,8 @@ spec: type: object type: array expectedSpiffeID: - description: ExpectedSpiffeID is the derived SPIFFE ID based on Kubernetes - metadata + description: ExpectedSpiffeID is the SPIFFE ID used for binding evaluation + (from JWS protected header) type: string lastSyncTime: description: LastSyncTime is when the agent card was last successfully @@ -314,9 +400,51 @@ spec: protocol: 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. + 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. + type: string + signatureVerificationDetails: + description: SignatureVerificationDetails contains details about the + last signature verification + type: string + targetRef: + description: |- + TargetRef contains the resolved reference to the backing workload. + This is populated after the controller successfully locates the workload. + properties: + apiVersion: + description: APIVersion is the API version of the target resource + (e.g., "apps/v1") + minLength: 1 + type: string + kind: + description: Kind is the kind of the target resource (e.g., "Deployment", + "StatefulSet") + minLength: 1 + type: string + name: + description: Name is the name of the target resource + minLength: 1 + type: string + required: + - apiVersion + - kind + - name + type: object validSignature: description: ValidSignature indicates if the agent card signature - was validated (future use) + was validated type: boolean type: object type: object diff --git a/charts/kagenti-operator/templates/manager/manager.yaml b/charts/kagenti-operator/templates/manager/manager.yaml index 29c4772b..3d740802 100644 --- a/charts/kagenti-operator/templates/manager/manager.yaml +++ b/charts/kagenti-operator/templates/manager/manager.yaml @@ -31,6 +31,28 @@ spec: {{- range .Values.controllerManager.container.args }} - {{ . }} {{- 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 }}" + {{- end }} + {{- if .Values.signatureVerification.secret.namespace }} + - "--signature-secret-namespace={{ .Values.signatureVerification.secret.namespace }}" + {{- end }} + {{- if .Values.signatureVerification.secret.key }} + - "--signature-secret-key={{ .Values.signatureVerification.secret.key }}" + {{- end }} + {{- if .Values.signatureVerification.jwks.url }} + - "--signature-jwks-url={{ .Values.signatureVerification.jwks.url }}" + {{- end }} + {{- if .Values.signatureVerification.enforceNetworkPolicies }} + - "--enforce-network-policies=true" + {{- end }} + {{- end }} command: - {{ .Values.controllerManager.container.cmd }} image: {{ .Values.controllerManager.container.image.repository }}:{{ .Values.controllerManager.container.image.tag }} diff --git a/charts/kagenti-operator/values.yaml b/charts/kagenti-operator/values.yaml index 9ffc0d3c..b00caf55 100644 --- a/charts/kagenti-operator/values.yaml +++ b/charts/kagenti-operator/values.yaml @@ -83,3 +83,22 @@ certmanager: # [NETWORK POLICIES]: To enable NetworkPolicies set true networkPolicy: enable: false + +# [SIGNATURE VERIFICATION]: A2A agent card signature verification +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 diff --git a/kagenti-operator/api/v1alpha1/agentcard_types.go b/kagenti-operator/api/v1alpha1/agentcard_types.go index 24903482..d434d3be 100644 --- a/kagenti-operator/api/v1alpha1/agentcard_types.go +++ b/kagenti-operator/api/v1alpha1/agentcard_types.go @@ -27,15 +27,12 @@ type AgentCardSpec struct { // +kubebuilder:default="30s" SyncPeriod string `json:"syncPeriod,omitempty"` - // 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. // +optional TargetRef *TargetRef `json:"targetRef,omitempty"` - // 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. + // Deprecated: Use TargetRef instead. If both are set, TargetRef takes precedence. // +optional Selector *AgentSelector `json:"selector,omitempty"` @@ -48,39 +45,33 @@ type AgentCardSpec struct { // +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 +// 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. type IdentityBinding struct { - // TrustDomain overrides the controller's default trust domain. - // Must be a valid DNS-like string without slashes. + // Deprecated: No longer used; trust domain comes from the JWS protected header. // +optional // +kubebuilder:validation:Pattern=`^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$` TrustDomain string `json:"trustDomain,omitempty"` - // ExpectedSpiffeID overrides the auto-derived SPIFFE ID for this workload. - // Use this when your SPIRE configuration uses a non-standard identity format - // (e.g., pod labels, container names, or other workload attestor selectors). - // If not specified, the controller derives the ID as: - // spiffe:///ns//sa/ - // See: https://github.com/spiffe/spire/blob/main/doc/plugin_agent_workloadattestor_k8s.md + // 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 that can bind to this agent. - // Each ID must be a valid SPIFFE ID in the format spiffe:/// + // 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 strict enforcement mode. When true and binding fails, - // the Agent controller will scale the deployment to 0. + // Strict enables enforcement mode: binding failures trigger network isolation. + // When false (default), results are recorded in status only (audit mode). // +optional // +kubebuilder:default=false Strict bool `json:"strict,omitempty"` } -// TargetRef identifies a workload that backs this agent using duck typing. -// This allows referencing any workload type (Deployment, StatefulSet, Job, etc.) -// without the controller needing explicit knowledge of each type. +// TargetRef identifies a workload backing this agent via duck typing. type TargetRef struct { // APIVersion is the API version of the target resource (e.g., "apps/v1") // +kubebuilder:validation:MinLength=1 @@ -126,15 +117,31 @@ type AgentCardStatus struct { // +optional TargetRef *TargetRef `json:"targetRef,omitempty"` - // ValidSignature indicates if the agent card signature was validated (future use) + // ValidSignature indicates if the agent card signature was validated // +optional ValidSignature *bool `json:"validSignature,omitempty"` + // SignatureVerificationDetails contains details about the last signature verification + // +optional + SignatureVerificationDetails string `json:"signatureVerificationDetails,omitempty"` + + // SignatureKeyID is the key ID used for verification (from JWS protected header kid) + // +optional + SignatureKeyID string `json:"signatureKeyId,omitempty"` + + // SignatureSpiffeID is the SPIFFE ID from the JWS protected header (set only when valid). + // +optional + SignatureSpiffeID string `json:"signatureSpiffeId,omitempty"` + + // SignatureIdentityMatch is true when both signature and identity binding pass. + // +optional + SignatureIdentityMatch *bool `json:"signatureIdentityMatch,omitempty"` + // CardId is the SHA256 hash of the JCS-canonicalized card content (optional drift detection) // +optional CardId string `json:"cardId,omitempty"` - // ExpectedSpiffeID is the derived SPIFFE ID based on Kubernetes metadata + // ExpectedSpiffeID is the SPIFFE ID used for binding evaluation (from JWS protected header) // +optional ExpectedSpiffeID string `json:"expectedSpiffeID,omitempty"` @@ -145,7 +152,7 @@ type AgentCardStatus struct { // BindingStatus represents the result of identity binding evaluation type BindingStatus struct { - // Bound indicates whether the expected SPIFFE ID is in the allowlist + // Bound indicates whether the verified SPIFFE ID is in the allowlist Bound bool `json:"bound"` // Reason is a machine-readable reason for the binding status @@ -199,6 +206,32 @@ type AgentCardData struct { // SupportsAuthenticatedExtendedCard indicates if the agent has an extended card // +optional SupportsAuthenticatedExtendedCard *bool `json:"supportsAuthenticatedExtendedCard,omitempty"` + + // Signatures contains JWS signatures per A2A spec §8.4.2. + // +optional + Signatures []AgentCardSignature `json:"signatures,omitempty"` +} + +// 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). + // +required + Protected string `json:"protected"` + + // Signature is the base64url-encoded JWS signature value. + // +required + Signature string `json:"signature"` + + // Header contains optional unprotected JWS header parameters. + // +optional + Header *SignatureHeader `json:"header,omitempty"` +} + +// SignatureHeader contains unprotected JWS header parameters. +type SignatureHeader struct { + // Timestamp is when the signature was created (ISO 8601 string) + // +optional + Timestamp string `json:"timestamp,omitempty"` } // AgentCapabilities defines A2A feature support @@ -265,6 +298,7 @@ type SkillParameter struct { // +kubebuilder:printcolumn:name="Kind",type="string",JSONPath=".status.targetRef.kind",description="Workload Kind" // +kubebuilder:printcolumn:name="Target",type="string",JSONPath=".status.targetRef.name",description="Target Workload" // +kubebuilder:printcolumn:name="Agent",type="string",JSONPath=".status.card.name",description="Agent Name" +// +kubebuilder:printcolumn:name="Verified",type="boolean",JSONPath=".status.validSignature",description="Signature Verified" // +kubebuilder:printcolumn:name="Bound",type="boolean",JSONPath=".status.bindingStatus.bound",description="Identity Bound" // +kubebuilder:printcolumn:name="Synced",type="string",JSONPath=".status.conditions[?(@.type=='Synced')].status",description="Sync Status" // +kubebuilder:printcolumn:name="LastSync",type="date",JSONPath=".status.lastSyncTime",description="Last Sync Time" diff --git a/kagenti-operator/api/v1alpha1/zz_generated.deepcopy.go b/kagenti-operator/api/v1alpha1/zz_generated.deepcopy.go index cd59949a..917ae44d 100644 --- a/kagenti-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/kagenti-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -258,6 +258,13 @@ func (in *AgentCardData) DeepCopyInto(out *AgentCardData) { *out = new(bool) **out = **in } + if in.Signatures != nil { + in, out := &in.Signatures, &out.Signatures + *out = make([]AgentCardSignature, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentCardData. @@ -302,6 +309,26 @@ func (in *AgentCardList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentCardSignature) DeepCopyInto(out *AgentCardSignature) { + *out = *in + if in.Header != nil { + in, out := &in.Header, &out.Header + *out = new(SignatureHeader) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentCardSignature. +func (in *AgentCardSignature) DeepCopy() *AgentCardSignature { + if in == nil { + return nil + } + out := new(AgentCardSignature) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentCardSpec) DeepCopyInto(out *AgentCardSpec) { *out = *in @@ -361,6 +388,11 @@ func (in *AgentCardStatus) DeepCopyInto(out *AgentCardStatus) { *out = new(bool) **out = **in } + if in.SignatureIdentityMatch != nil { + in, out := &in.SignatureIdentityMatch, &out.SignatureIdentityMatch + *out = new(bool) + **out = **in + } if in.BindingStatus != nil { in, out := &in.BindingStatus, &out.BindingStatus *out = new(BindingStatus) @@ -862,6 +894,21 @@ func (in *PipelineTemplate) DeepCopy() *PipelineTemplate { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SignatureHeader) DeepCopyInto(out *SignatureHeader) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SignatureHeader. +func (in *SignatureHeader) DeepCopy() *SignatureHeader { + if in == nil { + return nil + } + out := new(SignatureHeader) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SkillParameter) DeepCopyInto(out *SkillParameter) { *out = *in diff --git a/kagenti-operator/cmd/main.go b/kagenti-operator/cmd/main.go index 7b0d8843..ade63a7a 100644 --- a/kagenti-operator/cmd/main.go +++ b/kagenti-operator/cmd/main.go @@ -40,6 +40,7 @@ import ( agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" "github.com/kagenti/operator/internal/controller" "github.com/kagenti/operator/internal/distribution" + "github.com/kagenti/operator/internal/signature" webhookv1alpha1 "github.com/kagenti/operator/internal/webhook/v1alpha1" tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" // +kubebuilder:scaffold:imports @@ -68,9 +69,18 @@ func main() { var enableHTTP2 bool var tlsOpts []func(*tls.Config) var enableClientRegistration bool - var defaultTrustDomain string var enableLegacyAgentCRD bool + // 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 + 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.") @@ -90,10 +100,27 @@ func main() { "If set, HTTP/2 will be enabled for the metrics and webhook servers") flag.BoolVar(&enableClientRegistration, "enable-client-registration", true, "If set, Kagenti will register clients (agents and tools) in Keycloak") - flag.StringVar(&defaultTrustDomain, "default-trust-domain", "cluster.local", - "Default SPIFFE trust domain for identity binding evaluation") flag.BoolVar(&enableLegacyAgentCRD, "enable-legacy-agent-crd", true, "Enable support for legacy Agent CRD. Set to false after full migration to workload-based agents (Deployments/StatefulSets).") + + // 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") + opts := zap.Options{ Development: true, } @@ -250,16 +277,56 @@ func main() { os.Exit(1) } + // Initialize signature verification provider + var sigProvider signature.Provider + if requireA2ASignature { + sigConfig := &signature.Config{ + Type: signature.ProviderType(signatureProvider), + SecretName: signatureSecretName, + SecretNamespace: signatureSecretNamespace, + SecretKey: signatureSecretKey, + JWKSURL: signatureJWKSURL, + AuditMode: signatureAuditMode, + } + + var providerErr error + sigProvider, providerErr = signature.NewProvider(sigConfig) + if providerErr != nil { + setupLog.Error(providerErr, "unable to create signature provider") + os.Exit(1) + } + setupLog.Info("Signature verification enabled", + "provider", signatureProvider, + "auditMode", signatureAuditMode, + "requireSignature", requireA2ASignature) + } + if err = (&controller.AgentCardReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Recorder: mgr.GetEventRecorderFor("agentcard-controller"), - TrustDomain: defaultTrustDomain, EnableLegacyAgentCRD: enableLegacyAgentCRD, + SignatureProvider: sigProvider, + RequireSignature: requireA2ASignature, + SignatureAuditMode: signatureAuditMode, }).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(), + Scheme: mgr.GetScheme(), + EnforceNetworkPolicies: enforceNetworkPolicies, + EnableLegacyAgentCRD: enableLegacyAgentCRD, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "AgentCardNetworkPolicy") + os.Exit(1) + } + setupLog.Info("Network policy enforcement enabled for signature verification") + } // AgentCardSync controller now watches Deployments, StatefulSets, and optionally Agent CRDs // It automatically creates AgentCards for workloads with agent labels if err = (&controller.AgentCardSyncReconciler{ 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 3800e646..a47184bc 100644 --- a/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentcards.yaml +++ b/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentcards.yaml @@ -34,6 +34,10 @@ spec: jsonPath: .status.card.name name: Agent type: string + - description: Signature Verified + jsonPath: .status.validSignature + name: Verified + type: boolean - description: Identity Bound jsonPath: .status.bindingStatus.bound name: Bound @@ -80,7 +84,8 @@ spec: 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:/// + 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:/// @@ -90,24 +95,27 @@ spec: type: array expectedSpiffeID: description: |- - ExpectedSpiffeID overrides the auto-derived SPIFFE ID for this workload. - Use this when your SPIRE configuration uses a non-standard identity format - (e.g., pod labels, container names, or other workload attestor selectors). - If not specified, the controller derives the ID as: - spiffe:///ns//sa/ - See: https://github.com/spiffe/spire/blob/main/doc/plugin_agent_workloadattestor_k8s.md + 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. When true and binding fails, - the Agent controller will scale the deployment to 0. + 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. type: boolean trustDomain: description: |- - TrustDomain overrides the controller's default trust domain. - Must be a valid DNS-like string without slashes. + 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. pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$ type: string required: @@ -219,6 +227,40 @@ spec: name: 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). + items: + description: |- + AgentCardSignature represents a JWS signature on an AgentCard. + Follows the A2A specification section 8.4.2 — JWS JSON Serialization. + properties: + header: + description: Header contains optional unprotected JWS header + parameters. + properties: + timestamp: + description: Timestamp is when the signature was created + (ISO 8601 string) + 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://..."}. + type: string + signature: + description: |- + Signature is the base64url-encoded JWS signature value. + The signing input is: BASE64URL(protected) || '.' || BASE64URL(canonical_payload) + type: string + required: + - protected + - signature + type: object + type: array skills: description: Skills is a list of skills/capabilities this agent offers @@ -347,8 +389,8 @@ spec: type: object type: array expectedSpiffeID: - description: ExpectedSpiffeID is the derived SPIFFE ID based on Kubernetes - metadata + description: ExpectedSpiffeID is the SPIFFE ID used for binding evaluation + (from JWS protected header) type: string lastSyncTime: description: LastSyncTime is when the agent card was last successfully @@ -358,6 +400,24 @@ spec: protocol: 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. + 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. + type: string + signatureVerificationDetails: + description: SignatureVerificationDetails contains details about the + last signature verification + type: string targetRef: description: |- TargetRef contains the resolved reference to the backing workload. @@ -384,7 +444,7 @@ spec: type: object validSignature: description: ValidSignature indicates if the agent card signature - was validated (future use) + was validated type: boolean type: object type: object diff --git a/kagenti-operator/config/rbac/role.yaml b/kagenti-operator/config/rbac/role.yaml index f3eae399..40a5dd76 100644 --- a/kagenti-operator/config/rbac/role.yaml +++ b/kagenti-operator/config/rbac/role.yaml @@ -39,6 +39,8 @@ rules: - delete - get - list + - patch + - update - watch - apiGroups: - "" @@ -104,6 +106,20 @@ rules: verbs: - get - list + - patch + - update + - watch +- apiGroups: + - networking.k8s.io + resources: + - networkpolicies + verbs: + - create + - delete + - get + - list + - patch + - update - watch - apiGroups: - rbac.authorization.k8s.io diff --git a/kagenti-operator/config/samples/signature-verification/agent-with-signature.yaml b/kagenti-operator/config/samples/signature-verification/agent-with-signature.yaml new file mode 100644 index 00000000..1ea7b725 --- /dev/null +++ b/kagenti-operator/config/samples/signature-verification/agent-with-signature.yaml @@ -0,0 +1,34 @@ +# 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 new file mode 100644 index 00000000..56893e09 --- /dev/null +++ b/kagenti-operator/config/samples/signature-verification/kustomization.yaml @@ -0,0 +1,10 @@ +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 new file mode 100644 index 00000000..22705861 --- /dev/null +++ b/kagenti-operator/config/samples/signature-verification/secret-with-public-key.yaml @@ -0,0 +1,27 @@ +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/docs/a2a-signature-verification.md b/kagenti-operator/docs/a2a-signature-verification.md new file mode 100644 index 00000000..4eaad48f --- /dev/null +++ b/kagenti-operator/docs/a2a-signature-verification.md @@ -0,0 +1,804 @@ +# 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-svc.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 Agent + AgentCard +cat </dev/null; do sleep 2; done +kubectl wait --for=condition=Available deployment/weather-agent --timeout=120s +kubectl wait --for=jsonpath='.status.lastSyncTime' agentcard/weather-agent-card --timeout=60s +``` + +### Step 5: Verify Signature Verification + +```bash +kubectl get agentcard weather-agent-card -o wide +kubectl get agentcard weather-agent-card -o jsonpath='{.status.validSignature}' +``` + +**Expected:** `VERIFIED=true`, `validSignature=true` + +```yaml +status: + validSignature: true + signatureVerificationDetails: "JWS signature verified successfully (alg=RS256, kid=my-signing-key)" + signatureKeyId: "my-signing-key" + signatureSpiffeId: "" # empty unless --spiffe-id was used during signing + signatureIdentityMatch: null # Only set when identityBinding is configured in the AgentCard spec +``` + +**Setup complete.** The AgentCard signature is verified against the public key in the secret. + +--- + +## 5. Demo Scenarios + +All demos build on the `weather-agent` from Setup. + +### Demo 1: Unsigned Card (Rejected) + +Deploy an agent with no signature — it should be rejected: + +```bash +cat > unsigned-card.json << 'EOF' +{ + "name": "Rogue Agent", + "description": "No signature", + "version": "1.0.0", + "url": "http://rogue-agent-svc.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 </dev/null; do sleep 2; done +kubectl wait --for=condition=Available deployment/rogue-agent --timeout=120s +kubectl wait --for=jsonpath='.status.lastSyncTime' agentcard/rogue-agent-card --timeout=60s +kubectl get agentcard -o wide +``` + +**Expected:** `weather-agent-card` is `VERIFIED=true`, `rogue-agent-card` is `VERIFIED=false`. + +```bash +kubectl get agentcard rogue-agent-card -o jsonpath='{.status.signatureVerificationDetails}' +# "AgentCard does not contain any signatures" +``` + +--- + +### Demo 2: Wrong-Key Signature (Rejected) + +Sign a card with a **different** private key — the signature won't match the public key in the secret: + +```bash +# Generate a different key pair +openssl genrsa -out wrong-private-key.pem 2048 + +# Sign with the wrong key (keyId still says "my-signing-key") +cat > tampered-card.json << 'EOF' +{ + "name": "Tampered Agent", + "description": "Signed with wrong key", + "version": "1.0.0", + "url": "http://tampered-agent-svc.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 </dev/null; do sleep 2; done +kubectl wait --for=condition=Available deployment/tampered-agent --timeout=120s +kubectl wait --for=jsonpath='.status.lastSyncTime' agentcard/tampered-agent-card --timeout=60s +kubectl get agentcard -o wide +``` + +**Expected:** `tampered-agent-card` is `VERIFIED=false`, `signatureVerificationDetails: "JWS signature verification failed with all available keys"`. + +--- + +### Demo 3: Audit Mode + +Enable audit mode — invalid signatures are **accepted with warnings** instead of blocked: + +```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.auditMode=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 +kubectl get agentcard -o wide +``` + +**Expected:** + +| Agent | VERIFIED | SYNCED | Behavior | +|-------|----------|--------|----------| +| `weather-agent-card` | `true` | `True` | Valid signature accepted | +| `rogue-agent-card` | `true` | `True` | No signatures, audit mode: allowed | +| `tampered-agent-card` | `true` | `True` | Wrong key, audit mode: allowed | + +```bash +# Check audit mode logs +kubectl logs -n kagenti-system deployment/kagenti-controller-manager | grep -i "audit mode" +``` + +> **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 agent 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/identity-binding-quickstart.md b/kagenti-operator/docs/identity-binding-quickstart.md index 3e65d147..c4e0d146 100644 --- a/kagenti-operator/docs/identity-binding-quickstart.md +++ b/kagenti-operator/docs/identity-binding-quickstart.md @@ -2,11 +2,9 @@ **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. - -This guide walks you through setting up AgentCard workload identity binding. By the end, you'll have a working system where AgentCards are **declaratively bound to workload identities** derived from Kubernetes metadata. - -> **Note:** This implements Step 1 (policy-based binding using metadata-derived SPIFFE IDs). Runtime cryptographic enforcement via Service Mesh / Agent Gateway is planned for Step 2. +> **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. --- @@ -31,14 +29,14 @@ This guide walks you through setting up AgentCard workload identity binding. By | Without Identity Binding | With Identity Binding | |--------------------------|----------------------| -| Any workload can claim any AgentCard | Only workloads with matching SPIFFE IDs can serve specific AgentCards | +| Any workload with a valid signature can communicate | Only workloads with matching SPIFFE IDs in the allowlist get network access | ### Key Features -- **Declarative binding**: AgentCards bound to specific Kubernetes service accounts -- **SPIFFE-based identity**: Derived from namespace + service account -- **Strict enforcement**: Optionally disable agents that fail verification -- **Operational visibility**: Kubernetes Events for monitoring +- **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 --- @@ -48,9 +46,16 @@ This guide walks you through setting up AgentCard workload identity binding. By |------|---------|--------| | 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 @@ -72,55 +77,72 @@ cd kagenti-operator ```mermaid flowchart LR - A["Agent\n(ServiceAccount: my-sa)"] - B["AgentCard Controller\n(derives SPIFFE ID)"] - C{"SPIFFE ID\nin allowlist?"} - D["Bound=true"] - E["Bound=false"] - F{"Strict\nmode?"} - G["Log warning"] - H["Scale to 0"] - - A -->|metadata| B + 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| E - E --> F - F -->|No| G - F -->|Yes| H + 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 warnClass fill:#fff9c4,stroke:#f57f17,stroke-width:2px classDef failClass fill:#ffcdd2,stroke:#c62828,stroke-width:2px class A agentClass - class B,C,F operatorClass - class D successClass - class G warnClass - class E,H failClass + 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/ ``` -The operator derives this from Kubernetes metadata: -- **Trust Domain:** `identityBinding.trustDomain` or operator default (`cluster.local`) -- **Namespace:** `Agent.metadata.namespace` -- **ServiceAccount:** `podTemplateSpec.spec.serviceAccountName` (or `-sa` if not set) - **Example:** ``` -spiffe://cluster.local/ns/production/sa/weather-agent-sa +spiffe://cluster.local/ns/demo/sa/weather-sa ``` -### Detailed Architecture +### 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 -![AgentCard → Workload Identity Binding Architecture](images/kagenti-identity-binding-architecture.png) +### 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` | --- @@ -135,22 +157,41 @@ 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 +``` -# Install Tekton (required for AgentBuild controller) -kubectl apply -f https://storage.googleapis.com/tekton-releases/pipeline/latest/release.yaml -sleep 30 +### 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 ``` -### Step 2: Install Kagenti Operator +> ⚠️ **Security:** Keep `private-key.pem` secure. Never commit it to git. + +### Step 3: Install Kagenti Operator ```bash -cd kagenti-operator -make docker-build IMG=kagenti-operator:dev +# 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 -helm install kagenti-operator ../charts/kagenti-operator \ +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 @@ -163,33 +204,106 @@ 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 3: Deploy Test Agent with Identity Binding +### 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 < **Key Takeaway:** Identity binding requires unambiguous selectors. Ensure a 1:1 relationship between AgentCard and Agent. +# Events show the binding was re-evaluated +kubectl get events -n demo --sort-by='.lastTimestamp' | grep -i binding | tail -3 +``` + +**Key Takeaway:** When the allowlist is corrected, the controller automatically re-evaluates binding, restores the label, and NetworkPolicy allows traffic again. No manual intervention needed. --- @@ -407,11 +474,14 @@ kubectl get agentcard weather-card -n demo -o jsonpath='{.status.bindingStatus}' | Issue | Cause | Solution | |-------|-------|----------| -| `bindingStatus` is nil | No `identityBinding` configured | Add `spec.identityBinding` to AgentCard | -| `bound: false` | SPIFFE ID not in allowlist | Verify namespace/SA match `allowedSpiffeIDs` | -| Deployment not scaling to 0 | `strict: false` or selector mismatch | Set `strict: true`, verify labels match | -| `MultipleAgentsMatched` | Selector matches >1 Agent | Use more specific labels | -| Wrong `expectedSpiffeID` | SA not set, defaults to `-sa` | Explicitly set `serviceAccountName` | +| `"No SPIFFE ID in JWS protected header"` | Card signed without `--spiffe-id` | Re-sign the card with `--spiffe-id` to embed the workload identity | +| `"not in the allowlist"` | SPIFFE ID doesn't match any entry in `allowedSpiffeIDs` | Check the SPIFFE ID format matches exactly | +| `bindingStatus` is nil | No `identityBinding` configured in AgentCard spec | Add `spec.identityBinding` to the AgentCard | +| Label not on pods | Signature verification not enabled | Install operator with `signatureVerification.enabled=true` | +| NetworkPolicy not blocking | NetworkPolicy enforcement not enabled | Set `signatureVerification.enforceNetworkPolicies=true` | +| `"WorkloadNotFound"` | `targetRef` points to non-existent Deployment | Verify Deployment name matches `targetRef.name` | +| `"NotAgentWorkload"` | Deployment missing `kagenti.io/type=agent` label | Add the label to the Deployment metadata | +| `"NoProtocol"` | Deployment missing `kagenti.io/protocol` label | Add `kagenti.io/protocol: a2a` to the Deployment metadata | **Debug commands:** @@ -422,8 +492,14 @@ kubectl logs -n kagenti-system deployment/kagenti-controller-manager | grep -i b # Check events kubectl get events -n --sort-by='.lastTimestamp' | grep -i binding -# Verify derived SPIFFE ID -kubectl get agentcard -n -o jsonpath='{.status.expectedSpiffeID}' +# 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 ``` --- @@ -434,107 +510,73 @@ kubectl get agentcard -n -o jsonpath='{.status.expectedSpiffe ```yaml spec: + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: my-agent identityBinding: - trustDomain: "cluster.local" # Optional, defaults to operator config - expectedSpiffeID: "" # Optional, override auto-derived ID (see below) - allowedSpiffeIDs: # Required + allowedSpiffeIDs: # Required - "spiffe://cluster.local/ns/demo/sa/my-sa" - strict: false # Optional, default false ``` -### Custom SPIFFE ID Configurations +### Signing with SPIFFE ID -By default, the controller derives the expected SPIFFE ID as: -``` -spiffe:///ns//sa/ +```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 ``` -This matches the default SPIRE Helm operator configuration. However, SPIRE supports [richer identity patterns](https://github.com/spiffe/spire/blob/main/doc/plugin_agent_workloadattestor_k8s.md#k8s-selectors) including: -- Pod labels (`k8s:pod-label::`) -- Pod names (`k8s:pod-name:`) -- Container names (`k8s:container-name:`) -- And more... - -**If your SPIRE is configured with a custom identity pattern**, use the `expectedSpiffeID` field to explicitly specify the expected identity: +### Required Deployment Labels ```yaml -spec: - identityBinding: - # Override auto-derivation with your custom SPIFFE ID format - expectedSpiffeID: "spiffe://mycompany.local/cluster/prod/workload/weather-agent" - allowedSpiffeIDs: - - "spiffe://mycompany.local/cluster/prod/workload/weather-agent" - strict: true +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.expectedSpiffeID` | SPIFFE ID used for binding (explicit or derived) | -| `status.bindingStatus.bound` | `true` if ID in allowlist | -| `status.bindingStatus.reason` | `Bound`, `NotBound`, `AgentNotFound`, `MultipleAgentsMatched` | -| `agent.status.bindingEnforcement.disabledByBinding` | `true` if scaled to 0 | -| `agent.status.bindingEnforcement.originalReplicas` | Pre-disable replica count | +| `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 | -| `BindingEnforced` | Agent scaled to 0 | -| `BindingRestored` | Agent restored | -| `MultipleAgentsMatched` | Ambiguous selector | - -### Deployment Annotations (when disabled) - -```yaml -annotations: - kagenti.io/disabled-by: "identity-binding" - kagenti.io/disabled-reason: "Identity binding failed: ..." -``` - -### Configuring Trust Domain - -**Operator level** (default for all AgentCards): - -```bash -helm install kagenti-operator ./charts/kagenti-operator \ - --namespace kagenti-system \ - --set 'controllerManager.container.args={--leader-elect,--metrics-bind-address=:8443,--health-probe-bind-address=:8081,--webhook-cert-path=/tmp/k8s-webhook-server/serving-certs,--default-trust-domain=mycompany.local}' -``` - -**Per AgentCard** (overrides default): - -```yaml -spec: - identityBinding: - trustDomain: "mycompany.local" - allowedSpiffeIDs: - - "spiffe://mycompany.local/ns/prod/sa/my-agent" -``` - -> **Production Tip:** Standardize `trustDomain` via operator flag. Use per-AgentCard override only for special cases like cross-cluster federation. +| `BindingFailed` | SPIFFE ID not in allowlist or no SPIFFE ID in JWS header | +| `SignatureEvaluated` | Signature verified successfully | +| `SignatureFailed` | Signature verification failed | ### Controller Ownership -| Controller | Owns | Responsibilities | -|------------|------|------------------| -| AgentCard Controller | `AgentCard.status` | Derive SPIFFE ID, evaluate binding | -| Agent Controller | `Agent.status`, Deployment | Enforce bindings, restore agents | - -### Multiple AgentCards per Agent - -If multiple AgentCards select one Agent: - -- **Enforcement:** If any has `strict=true` AND `bound=false`, Agent is disabled -- **Restoration:** Agent restored when all strict cards are `bound=true` - -### Restore Semantics - -- Original replica count stored in `Agent.status.bindingEnforcement.originalReplicas` -- Restoration uses `originalReplicas`, not current `spec.replicas` +| Controller | Responsibilities | +|------------|------------------| +| AgentCard Controller | Verify signature, extract SPIFFE ID, evaluate binding, propagate labels | +| NetworkPolicy Controller | Create permissive/restrictive policies based on `signature-verified` label | --- @@ -543,8 +585,11 @@ If multiple AgentCards select one Agent: ```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 ``` --- @@ -553,8 +598,13 @@ kind delete cluster --name kagenti-demo | What You Built | Description | |----------------|-------------| -| Policy-based binding | AgentCards bound to SPIFFE IDs derived from K8s metadata | -| Strict enforcement | Agents scaled to 0 on mismatch | -| Self-healing | Automatic restoration when fixed | - -**Next (Step 2):** Runtime mTLS enforcement via Service Mesh / Agent Gateway. +| 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/jwks_guide.md b/kagenti-operator/docs/jwks_guide.md new file mode 100644 index 00000000..6a990b74 --- /dev/null +++ b/kagenti-operator/docs/jwks_guide.md @@ -0,0 +1,432 @@ +# 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/internal/controller/agent_controller.go b/kagenti-operator/internal/controller/agent_controller.go index e47c8685..4c887a0c 100644 --- a/kagenti-operator/internal/controller/agent_controller.go +++ b/kagenti-operator/internal/controller/agent_controller.go @@ -61,6 +61,13 @@ const ( CLIENT_REGISTRATION_NAME = "kagenti-client-registration" SPIFFY_HELPER_NAME = "spiffe-helper" AGENT_FINALIZER = "agent.kagenti.dev/finalizer" + + // Annotation keys for binding enforcement (legacy Agent CRD) + AnnotationDisabledBy = "kagenti.io/disabled-by" + AnnotationDisabledReason = "kagenti.io/disabled-reason" + + // Annotation values + DisabledByIdentityBinding = "identity-binding" ) // +kubebuilder:rbac:groups=agent.kagenti.dev,resources=agents,verbs=get;list;watch;create;update;patch;delete @@ -903,9 +910,16 @@ func (r *AgentReconciler) restoreAgentFromBinding(ctx context.Context, agent *ag return ctrl.Result{}, nil } -// agentCardSelectsAgent checks if an AgentCard's selector matches an Agent +// agentCardSelectsAgent checks if an AgentCard references this Agent via targetRef or selector. func (r *AgentReconciler) agentCardSelectsAgent(card *agentv1alpha1.AgentCard, agent *agentv1alpha1.Agent) bool { - if agent.Labels == nil { + // Check targetRef first (preferred) + if card.Spec.TargetRef != nil { + return card.Spec.TargetRef.Kind == "Agent" && + card.Spec.TargetRef.Name == agent.Name + } + + // Fall back to selector (legacy) — guard against nil + if card.Spec.Selector == nil || agent.Labels == nil { return false } for key, value := range card.Spec.Selector.MatchLabels { diff --git a/kagenti-operator/internal/controller/agentcard_controller.go b/kagenti-operator/internal/controller/agentcard_controller.go index 161cfa14..888c75ed 100644 --- a/kagenti-operator/internal/controller/agentcard_controller.go +++ b/kagenti-operator/internal/controller/agentcard_controller.go @@ -46,6 +46,7 @@ import ( agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" "github.com/kagenti/operator/internal/agentcard" + "github.com/kagenti/operator/internal/signature" ) const ( @@ -57,29 +58,26 @@ const ( // 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 = "agent.kagenti.dev/signature-verified" + // Finalizer AgentCardFinalizer = "agentcard.kagenti.dev/finalizer" // Default sync period DefaultSyncPeriod = 30 * time.Second - // Default trust domain for SPIFFE IDs - DefaultTrustDomain = "cluster.local" - - // Annotation keys for binding enforcement - AnnotationDisabledBy = "kagenti.io/disabled-by" - AnnotationDisabledReason = "kagenti.io/disabled-reason" - - // Annotation values - DisabledByIdentityBinding = "identity-binding" - // Binding status reasons ReasonBound = "Bound" ReasonNotBound = "NotBound" ReasonAgentNotFound = "AgentNotFound" ReasonMultipleAgentsMatched = "MultipleAgentsMatched" - ReasonNoTrustDomain = "NoTrustDomain" - ReasonNoIdentityConfig = "NoIdentityConfig" + + // Signature verification reasons + ReasonSignatureValid = "SignatureValid" + ReasonSignatureInvalid = "SignatureInvalid" + ReasonSignatureInvalidAudit = "SignatureInvalidAudit" ) var ( @@ -109,8 +107,12 @@ type AgentCardReconciler struct { Scheme *runtime.Scheme AgentFetcher agentcard.Fetcher Recorder record.EventRecorder - TrustDomain string EnableLegacyAgentCRD bool + + // Signature verification + SignatureProvider signature.Provider + RequireSignature bool + SignatureAuditMode bool } // +kubebuilder:rbac:groups=agent.kagenti.dev,resources=agentcards,verbs=get;list;watch;create;update;patch;delete @@ -118,8 +120,9 @@ type AgentCardReconciler struct { // +kubebuilder:rbac:groups=agent.kagenti.dev,resources=agentcards/finalizers,verbs=update // +kubebuilder:rbac:groups=agent.kagenti.dev,resources=agents,verbs=get;list;watch // +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch -// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch -// +kubebuilder:rbac:groups=apps,resources=statefulsets,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 func (r *AgentCardReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { agentCardLogger.Info("Reconciling AgentCard", "namespacedName", req.NamespacedName) @@ -175,7 +178,6 @@ func (r *AgentCardReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // If identity binding is configured, update binding status if agentCard.Spec.IdentityBinding != nil { r.updateBindingStatus(ctx, agentCard, false, reason, message, "") - // Emit event for visibility if r.Recorder != nil { r.Recorder.Event(agentCard, corev1.EventTypeWarning, reason, message) } @@ -183,13 +185,6 @@ func (r *AgentCardReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil } - // Evaluate identity binding BEFORE fetching the card (uses only K8s metadata) - if agentCard.Spec.IdentityBinding != nil { - if err := r.evaluateBinding(ctx, agentCard, workload); err != nil { - agentCardLogger.Error(err, "Failed to evaluate binding", "agentCard", agentCard.Name) - } - } - // Check if workload is ready if !workload.Ready { agentCardLogger.Info("Workload not ready yet, skipping sync", "workload", workload.Name, "kind", workload.Kind) @@ -227,16 +222,45 @@ func (r *AgentCardReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( 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 + verificationResult, verifyErr = r.verifySignature(ctx, cardData) + + if verifyErr != nil { + 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 { + r.Recorder.Event(agentCard, corev1.EventTypeNormal, "SignatureEvaluated", + fmt.Sprintf("Signature verified successfully (keyID=%s)", verificationResult.KeyID)) + } + } else { + reason := ReasonSignatureInvalid + if r.SignatureAuditMode { + reason = ReasonSignatureInvalidAudit + } + agentCardLogger.Info("Signature verification failed", + "workload", workload.Name, + "reason", reason, + "details", verificationResult.Details) + if r.Recorder != nil { + r.Recorder.Event(agentCard, corev1.EventTypeWarning, "SignatureFailed", verificationResult.Details) + } + } + } + } + // Override the URL with the actual in-cluster Service URL. - // Agents may advertise URLs like 0.0.0.0:8000, which are only valid from - // within the agent Pod itself and are not usable for cluster communication. - // Using the Service URL ensures other components can reliably reach the agent. cardData.URL = serviceURL // Compute card_id for drift detection (optional) cardId := r.computeCardId(cardData) if cardId != "" && agentCard.Status.CardId != "" && agentCard.Status.CardId != cardId { - // Card content has changed - emit event if r.Recorder != nil { r.Recorder.Event(agentCard, corev1.EventTypeWarning, "CardContentChanged", fmt.Sprintf("Agent card content changed: previous=%s, current=%s", agentCard.Status.CardId, cardId)) @@ -251,12 +275,53 @@ func (r *AgentCardReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( Name: workload.Name, } - // Update the AgentCard status with the fetched card - if err := r.updateAgentCardStatus(ctx, agentCard, cardData, protocol, cardId, resolvedTargetRef); err != nil { + // Compute binding before the status write so everything is persisted in one API call. + var bindingPassed bool + var binding *bindingResult + var identityMatch *bool + sigVerified := verificationResult != nil && verificationResult.Verified + if agentCard.Spec.IdentityBinding != nil { + var verifiedSpiffeID string + if verificationResult != nil && verificationResult.Verified && verificationResult.SpiffeID != "" { + verifiedSpiffeID = verificationResult.SpiffeID + } + binding = r.computeBinding(agentCard, verifiedSpiffeID) + bindingPassed = binding != nil && binding.Bound + match := sigVerified && bindingPassed + identityMatch = &match + } + + // Persist all status fields in one write. + var vr *signature.VerificationResult + if r.RequireSignature { + vr = verificationResult + } + if err := r.updateAgentCardStatus(ctx, agentCard, cardData, protocol, cardId, resolvedTargetRef, vr, binding, identityMatch); err != nil { agentCardLogger.Error(err, "Failed to update AgentCard status") 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. + if r.RequireSignature { + isVerified := sigVerified + if agentCard.Spec.IdentityBinding != nil { + isVerified = isVerified && bindingPassed + } + if err := r.propagateSignatureLabel(ctx, workload, isVerified); err != nil { + agentCardLogger.Error(err, "Failed to propagate signature label to workload", + "workload", workload.Name, "verified", isVerified) + } + + // 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, + "details", verificationResult.Details) + return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil + } + } + // 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) @@ -607,8 +672,9 @@ func (r *AgentCardReconciler) getSyncPeriod(agentCard *agentv1alpha1.AgentCard) return duration } -// updateAgentCardStatus updates the AgentCard status with the fetched agent card -func (r *AgentCardReconciler) updateAgentCardStatus(ctx context.Context, agentCard *agentv1alpha1.AgentCard, cardData *agentv1alpha1.AgentCardData, protocol, cardId string, targetRef *agentv1alpha1.TargetRef) error { +// updateAgentCardStatus persists all status fields in a single write. +// binding and identityMatch are nil when identity binding is not configured. +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{} @@ -628,14 +694,62 @@ func (r *AgentCardReconciler) updateAgentCardStatus(ctx context.Context, agentCa latest.Status.CardId = cardId } - // Update conditions - meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ - Type: "Synced", - Status: metav1.ConditionTrue, - LastTransitionTime: metav1.Now(), - Reason: "SyncSucceeded", - Message: fmt.Sprintf("Successfully fetched agent card for %s", cardData.Name), - }) + // 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(), + } + if verificationResult.Verified { + sigCondition.Status = metav1.ConditionTrue + sigCondition.Reason = ReasonSignatureValid + sigCondition.Message = verificationResult.Details + } else { + sigCondition.Status = metav1.ConditionFalse + if r.SignatureAuditMode { + sigCondition.Reason = ReasonSignatureInvalidAudit + sigCondition.Message = verificationResult.Details + " (audit mode: allowed)" + } else { + sigCondition.Reason = ReasonSignatureInvalid + sigCondition.Message = verificationResult.Details + } + } + 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, + }) + } else { + message := fmt.Sprintf("Successfully fetched agent card for %s", cardData.Name) + if verificationResult != nil && !verificationResult.Verified && r.SignatureAuditMode { + 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, + }) + } meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ Type: "Ready", @@ -645,10 +759,137 @@ func (r *AgentCardReconciler) updateAgentCardStatus(ctx context.Context, agentCa Message: "Agent index is ready for queries", }) + // Write binding status if computed + if binding != nil { + now := metav1.Now() + latest.Status.BindingStatus = &agentv1alpha1.BindingStatus{ + Bound: binding.Bound, + Reason: binding.Reason, + Message: binding.Message, + LastEvaluationTime: &now, + } + if binding.SpiffeID != "" { + latest.Status.ExpectedSpiffeID = binding.SpiffeID + } + conditionStatus := metav1.ConditionFalse + if binding.Bound { + conditionStatus = metav1.ConditionTrue + } + meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ + Type: "Bound", + Status: conditionStatus, + LastTransitionTime: now, + 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. +func (r *AgentCardReconciler) verifySignature(ctx context.Context, cardData *agentv1alpha1.AgentCardData) (*signature.VerificationResult, error) { + startTime := time.Now() + defer func() { + duration := time.Since(startTime).Seconds() + signature.SignatureVerificationDuration.WithLabelValues(r.SignatureProvider.Name()).Observe(duration) + }() + + result, err := r.SignatureProvider.VerifySignature(ctx, cardData, cardData.Signatures) + + // Ensure result is never nil + if result == nil { + result = &signature.VerificationResult{ + Verified: false, + Details: "Verification returned null result", + } + } + + // Record metrics + signature.RecordVerification(r.SignatureProvider.Name(), result.Verified, r.SignatureAuditMode) + if err != nil { + signature.RecordError(r.SignatureProvider.Name(), "verification_error") + } + + 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} + + switch workload.Kind { + case "Deployment": + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + deployment := &appsv1.Deployment{} + if err := r.Get(ctx, key, deployment); err != nil { + return err + } + if deployment.Spec.Template.Labels == nil { + deployment.Spec.Template.Labels = make(map[string]string) + } + current := deployment.Spec.Template.Labels[LabelSignatureVerified] + // No change needed — avoid unnecessary rollout + if verified && current == "true" { + return nil + } + if !verified && current == "" { + return nil + } + if verified { + deployment.Spec.Template.Labels[LabelSignatureVerified] = "true" + } else { + delete(deployment.Spec.Template.Labels, LabelSignatureVerified) + } + agentCardLogger.Info("Propagating signature-verified label to Deployment pod template", + "deployment", workload.Name, + "verified", verified) + return r.Update(ctx, deployment) + }) + + case "StatefulSet": + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + statefulSet := &appsv1.StatefulSet{} + if err := r.Get(ctx, key, statefulSet); err != nil { + return err + } + if statefulSet.Spec.Template.Labels == nil { + statefulSet.Spec.Template.Labels = make(map[string]string) + } + current := statefulSet.Spec.Template.Labels[LabelSignatureVerified] + if verified && current == "true" { + return nil + } + if !verified && current == "" { + return nil + } + if verified { + statefulSet.Spec.Template.Labels[LabelSignatureVerified] = "true" + } else { + delete(statefulSet.Spec.Template.Labels, LabelSignatureVerified) + } + agentCardLogger.Info("Propagating signature-verified label to StatefulSet pod template", + "statefulSet", workload.Name, + "verified", verified) + return r.Update(ctx, statefulSet) + }) + + default: + agentCardLogger.V(1).Info("Cannot propagate signature label to unsupported workload kind", + "kind", workload.Kind, "workload", workload.Name) + return nil + } +} + // updateCondition updates a specific condition func (r *AgentCardReconciler) updateCondition(ctx context.Context, agentCard *agentv1alpha1.AgentCard, conditionType string, status metav1.ConditionStatus, reason, message string) { _ = retry.RetryOnConflict(retry.DefaultRetry, func() error { @@ -839,79 +1080,79 @@ func agentLabelPredicate() predicate.Predicate { }) } -// evaluateBinding evaluates identity binding using Kubernetes metadata only -// Works with WorkloadInfo to support any workload type (Deployment, StatefulSet, Agent CRD) -func (r *AgentCardReconciler) evaluateBinding(ctx context.Context, agentCard *agentv1alpha1.AgentCard, workload *WorkloadInfo) error { +// bindingResult holds the computed identity binding state (pure logic, no API call). +type bindingResult struct { + Bound bool + Reason string + Message string + SpiffeID string // the verified SPIFFE ID used for the evaluation +} + +// 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). +func (r *AgentCardReconciler) computeBinding(agentCard *agentv1alpha1.AgentCard, verifiedSpiffeID string) *bindingResult { binding := agentCard.Spec.IdentityBinding if binding == nil { return nil } - // Determine expected SPIFFE ID - var expectedSpiffeID string - - // Use explicit expectedSpiffeID if provided (supports custom SPIRE configurations) - if binding.ExpectedSpiffeID != "" { - expectedSpiffeID = string(binding.ExpectedSpiffeID) - agentCardLogger.V(1).Info("Using explicit expectedSpiffeID from spec", "expectedSpiffeID", expectedSpiffeID) - } else { - // Derive from Kubernetes metadata (standard SPIRE Helm operator pattern) - // See: https://github.com/spiffe/spire/blob/main/doc/plugin_agent_workloadattestor_k8s.md - - // Determine trust domain - trustDomain := binding.TrustDomain - if trustDomain == "" { - trustDomain = r.TrustDomain - } - if trustDomain == "" { - trustDomain = DefaultTrustDomain - } - - // Get service account from the workload - serviceAccount, err := r.getWorkloadServiceAccount(ctx, workload) - if err != nil { - agentCardLogger.Error(err, "Failed to get service account for workload", "workload", workload.Name) - serviceAccount = workload.Name + "-sa" // fallback + // 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") + if r.Recorder != nil { + r.Recorder.Event(agentCard, corev1.EventTypeWarning, "BindingFailed", message) } + return &bindingResult{Bound: false, Reason: reason, Message: message} + } - // Convention: spiffe:///ns//sa/ - expectedSpiffeID = fmt.Sprintf("spiffe://%s/ns/%s/sa/%s", trustDomain, workload.Namespace, serviceAccount) + // Warn that binding is allowlist-only until SPIFFE trust bundle verification is available. + agentCardLogger.Info("Identity binding is allowlist-only; SPIFFE trust bundle verification not yet available", + "agentCard", agentCard.Name) + if r.Recorder != nil { + r.Recorder.Event(agentCard, corev1.EventTypeWarning, "AllowlistOnly", + "Identity binding is allowlist-only; SPIFFE trust bundle verification not yet available") } - // Check if expected SPIFFE ID is in the allowlist + // Check if verified SPIFFE ID is in the allowlist bound := false for _, allowedID := range binding.AllowedSpiffeIDs { - if string(allowedID) == expectedSpiffeID { + if string(allowedID) == verifiedSpiffeID { bound = true break } } - // Warn if expectedSpiffeID doesn't match any allowedSpiffeID (likely config error) - if !bound && r.Recorder != nil { - // Log at higher verbosity to help users debug misconfigurations - agentCardLogger.Info("SPIFFE ID mismatch - verify your SPIRE configuration matches allowedSpiffeIDs", - "expectedSpiffeID", expectedSpiffeID, + if !bound { + agentCardLogger.Info("SPIFFE ID mismatch", + "verifiedSpiffeID", verifiedSpiffeID, "allowedSpiffeIDs", binding.AllowedSpiffeIDs, - "hint", "If using custom SPIRE identity patterns, set spec.identityBinding.expectedSpiffeID explicitly") + "hint", "Ensure the spiffe_id in the JWS protected header matches an entry in allowedSpiffeIDs") } // Determine reason and message var reason, message string if bound { reason = ReasonBound - message = fmt.Sprintf("Expected SPIFFE ID %s is in the allowlist", expectedSpiffeID) + message = fmt.Sprintf("SPIFFE ID %s (source: jws-protected-header) is in the allowlist", verifiedSpiffeID) } else { reason = ReasonNotBound - message = fmt.Sprintf("Expected SPIFFE ID %s is not in the allowlist", expectedSpiffeID) + message = fmt.Sprintf("SPIFFE ID %s (source: jws-protected-header) is not in the allowlist", verifiedSpiffeID) } - // Update binding status - if err := r.updateBindingStatus(ctx, agentCard, bound, reason, message, expectedSpiffeID); err != nil { - return err - } - - // Emit events + // Emit events for immediate kubectl visibility if r.Recorder != nil { if bound { r.Recorder.Event(agentCard, corev1.EventTypeNormal, "BindingEvaluated", message) @@ -920,50 +1161,11 @@ func (r *AgentCardReconciler) evaluateBinding(ctx context.Context, agentCard *ag } } - return nil -} - -// getWorkloadServiceAccount returns the service account name for a workload -func (r *AgentCardReconciler) getWorkloadServiceAccount(ctx context.Context, workload *WorkloadInfo) (string, error) { - switch workload.Kind { - case "Agent": - // For Agent CRD, get the Agent and check its PodTemplateSpec - agent := &agentv1alpha1.Agent{} - if err := r.Get(ctx, types.NamespacedName{Name: workload.Name, Namespace: workload.Namespace}, agent); err != nil { - return "", err - } - if agent.Spec.PodTemplateSpec != nil && agent.Spec.PodTemplateSpec.Spec.ServiceAccountName != "" { - return agent.Spec.PodTemplateSpec.Spec.ServiceAccountName, nil - } - return agent.Name + "-sa", nil - - case "Deployment": - deployment := &appsv1.Deployment{} - if err := r.Get(ctx, types.NamespacedName{Name: workload.Name, Namespace: workload.Namespace}, deployment); err != nil { - return "", err - } - if deployment.Spec.Template.Spec.ServiceAccountName != "" { - return deployment.Spec.Template.Spec.ServiceAccountName, nil - } - return "default", nil - - case "StatefulSet": - statefulset := &appsv1.StatefulSet{} - if err := r.Get(ctx, types.NamespacedName{Name: workload.Name, Namespace: workload.Namespace}, statefulset); err != nil { - return "", err - } - if statefulset.Spec.Template.Spec.ServiceAccountName != "" { - return statefulset.Spec.Template.Spec.ServiceAccountName, nil - } - return "default", nil - - default: - // For unknown types, try to get from unstructured - return workload.Name + "-sa", nil - } + return &bindingResult{Bound: bound, Reason: reason, Message: message, SpiffeID: verifiedSpiffeID} } -// updateBindingStatus updates the binding status in the AgentCard +// updateBindingStatus updates binding status when the main status write is unreachable +// (e.g. getWorkload fails before card data is available). 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{} @@ -985,7 +1187,6 @@ func (r *AgentCardReconciler) updateBindingStatus(ctx context.Context, agentCard latest.Status.ExpectedSpiffeID = expectedSpiffeID } - // Update the Bound condition conditionStatus := metav1.ConditionFalse if bound { conditionStatus = metav1.ConditionTrue @@ -1007,7 +1208,7 @@ func (r *AgentCardReconciler) computeCardId(cardData *agentv1alpha1.AgentCardDat if cardData == nil { return "" } - // Use JSON serialization for simplicity (JCS would be ideal but adds complexity) + // JSON serialization is sufficient — hash is only compared within this operator data, err := json.Marshal(cardData) if err != nil { agentCardLogger.Error(err, "Failed to marshal card data for hash computation") @@ -1024,6 +1225,15 @@ func (r *AgentCardReconciler) SetupWithManager(mgr ctrl.Manager) error { r.AgentFetcher = agentcard.NewFetcher() } + // Initialize the signature provider if not set + if r.SignatureProvider == nil { + r.SignatureProvider = signature.NewNoOpProvider() + } + // Inject the Kubernetes client into providers that need it + if secretProvider, ok := r.SignatureProvider.(*signature.SecretProvider); ok { + secretProvider.SetClient(mgr.GetClient()) + } + controllerBuilder := ctrl.NewControllerManagedBy(mgr). For(&agentv1alpha1.AgentCard{}). // Watch Deployments with agent labels diff --git a/kagenti-operator/internal/controller/agentcard_networkpolicy_controller.go b/kagenti-operator/internal/controller/agentcard_networkpolicy_controller.go new file mode 100644 index 00000000..346e97a0 --- /dev/null +++ b/kagenti-operator/internal/controller/agentcard_networkpolicy_controller.go @@ -0,0 +1,577 @@ +/* +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 controller + +import ( + "context" + "fmt" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/client-go/util/retry" + 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" +) + +var ( + networkPolicyLogger = ctrl.Log.WithName("controller").WithName("AgentCardNetworkPolicy") +) + +// AgentCardNetworkPolicyReconciler manages NetworkPolicies based on AgentCard +// signature verification status. +type AgentCardNetworkPolicyReconciler struct { + client.Client + Scheme *runtime.Scheme + EnforceNetworkPolicies bool + EnableLegacyAgentCRD bool +} + +// +kubebuilder:rbac:groups=networking.k8s.io,resources=networkpolicies,verbs=get;list;watch;create;update;patch;delete +// +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) + + // Skip if network policy enforcement is disabled + if !r.EnforceNetworkPolicies { + return ctrl.Result{}, nil + } + + agentCard := &agentv1alpha1.AgentCard{} + err := r.Get(ctx, req.NamespacedName, agentCard) + if err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + 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") + 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 + } + + return ctrl.Result{}, nil +} + +// resolveWorkload resolves the workload name and pod selector labels from the AgentCard. +func (r *AgentCardNetworkPolicyReconciler) resolveWorkload(ctx context.Context, agentCard *agentv1alpha1.AgentCard) (string, map[string]string, error) { + // Prefer spec.targetRef + 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 + } + + // Try status.targetRef (populated by AgentCardReconciler after resolving selector) + if agentCard.Status.TargetRef != nil { + ref := agentCard.Status.TargetRef + podLabels, err := r.getPodTemplateLabels(ctx, agentCard.Namespace, ref) + if err == nil { + return ref.Name, podLabels, nil + } + // Fall through to selector if status targetRef lookup fails + } + + // Fall back to selector (legacy) + if agentCard.Spec.Selector != nil && len(agentCard.Spec.Selector.MatchLabels) > 0 { + return agentCard.Name, agentCard.Spec.Selector.MatchLabels, nil + } + + return "", nil, fmt.Errorf("neither targetRef nor selector specified") +} + +// 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} + + switch ref.Kind { + case "Deployment": + deployment := &appsv1.Deployment{} + if err := r.Get(ctx, key, deployment); err != nil { + return nil, err + } + return deployment.Spec.Template.Labels, nil + + case "StatefulSet": + statefulset := &appsv1.StatefulSet{} + if err := r.Get(ctx, key, statefulset); err != nil { + return nil, err + } + return statefulset.Spec.Template.Labels, nil + + case "Agent": + agent := &agentv1alpha1.Agent{} + if err := r.Get(ctx, key, agent); err != nil { + return nil, err + } + if agent.Spec.PodTemplateSpec != nil { + return agent.Spec.PodTemplateSpec.Labels, nil + } + // Fallback: use agent labels + return agent.Labels, nil + + default: + // For unknown workload types, use the agent card name as a selector + return map[string]string{ + LabelAgentType: LabelValueAgent, + "app": ref.Name, + }, nil + } +} + +// 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 + } + + if isVerified { + return r.createPermissivePolicy(ctx, policyName, agentCard, podSelectorLabels) + } + return r.createRestrictivePolicy(ctx, policyName, agentCard, podSelectorLabels) +} + +// 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 { + policy := &netv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: policyName, + Namespace: agentCard.Namespace, + Labels: map[string]string{ + "managed-by": "kagenti-operator", + "kagenti.dev/agentcard": agentCard.Name, + "kagenti.dev/policy-type": "signature-verification", + }, + }, + Spec: netv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{ + MatchLabels: podSelectorLabels, + }, + PolicyTypes: []netv1.PolicyType{ + netv1.PolicyTypeIngress, + netv1.PolicyTypeEgress, + }, + Ingress: []netv1.NetworkPolicyIngressRule{ + { + From: []netv1.NetworkPolicyPeer{ + { + // Allow traffic from other verified agents + PodSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + LabelSignatureVerified: "true", + }, + }, + }, + { + // Allow traffic from operator/control plane + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "control-plane": "kagenti-operator", + }, + }, + }, + }, + }, + }, + Egress: []netv1.NetworkPolicyEgressRule{ + { + // Allow egress to other verified agents + To: []netv1.NetworkPolicyPeer{ + { + PodSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + LabelSignatureVerified: "true", + }, + }, + }, + }, + }, + { + // Allow DNS queries + To: []netv1.NetworkPolicyPeer{ + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "kubernetes.io/metadata.name": "kube-system", + }, + }, + }, + }, + Ports: []netv1.NetworkPolicyPort{ + { + Protocol: func() *corev1.Protocol { p := corev1.ProtocolUDP; return &p }(), + Port: &intstr.IntOrString{Type: intstr.Int, IntVal: 53}, + }, + { + Protocol: func() *corev1.Protocol { p := corev1.ProtocolTCP; return &p }(), + Port: &intstr.IntOrString{Type: intstr.Int, IntVal: 53}, + }, + }, + }, + { + // Allow egress to API server + To: []netv1.NetworkPolicyPeer{ + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "kubernetes.io/metadata.name": "default", + }, + }, + PodSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "component": "apiserver", + }, + }, + }, + }, + }, + }, + }, + } + + // Set owner reference + if err := controllerutil.SetControllerReference(agentCard, policy, r.Scheme); err != nil { + return fmt.Errorf("failed to set controller reference: %w", err) + } + + // Create or update the policy + existingPolicy := &netv1.NetworkPolicy{} + err := r.Get(ctx, types.NamespacedName{Name: policyName, Namespace: agentCard.Namespace}, existingPolicy) + if err != nil { + if apierrors.IsNotFound(err) { + networkPolicyLogger.Info("Creating permissive NetworkPolicy for verified agent", + "agentCard", agentCard.Name, + "policy", policyName) + return r.Create(ctx, policy) + } + return err + } + + existingPolicy.Spec = policy.Spec + networkPolicyLogger.Info("Updating NetworkPolicy to permissive for verified agent", + "agentCard", agentCard.Name, + "policy", policyName) + return r.Update(ctx, existingPolicy) +} + +// 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 { + policy := &netv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: policyName, + Namespace: agentCard.Namespace, + Labels: map[string]string{ + "managed-by": "kagenti-operator", + "kagenti.dev/agentcard": agentCard.Name, + "kagenti.dev/policy-type": "signature-verification", + }, + }, + Spec: netv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{ + MatchLabels: podSelectorLabels, + }, + PolicyTypes: []netv1.PolicyType{ + netv1.PolicyTypeIngress, + netv1.PolicyTypeEgress, + }, + Ingress: []netv1.NetworkPolicyIngressRule{ + { + // Only allow traffic from operator for health checks + From: []netv1.NetworkPolicyPeer{ + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "control-plane": "kagenti-operator", + }, + }, + }, + }, + }, + }, + Egress: []netv1.NetworkPolicyEgressRule{ + { + // Allow DNS queries only + To: []netv1.NetworkPolicyPeer{ + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "kubernetes.io/metadata.name": "kube-system", + }, + }, + }, + }, + Ports: []netv1.NetworkPolicyPort{ + { + Protocol: func() *corev1.Protocol { p := corev1.ProtocolUDP; return &p }(), + Port: &intstr.IntOrString{Type: intstr.Int, IntVal: 53}, + }, + { + Protocol: func() *corev1.Protocol { p := corev1.ProtocolTCP; return &p }(), + Port: &intstr.IntOrString{Type: intstr.Int, IntVal: 53}, + }, + }, + }, + }, + }, + } + + // Set owner reference + if err := controllerutil.SetControllerReference(agentCard, policy, r.Scheme); err != nil { + return fmt.Errorf("failed to set controller reference: %w", err) + } + + // Create or update the policy + existingPolicy := &netv1.NetworkPolicy{} + err := r.Get(ctx, types.NamespacedName{Name: policyName, Namespace: agentCard.Namespace}, existingPolicy) + if err != nil { + if apierrors.IsNotFound(err) { + networkPolicyLogger.Info("Creating restrictive NetworkPolicy for unverified agent", + "agentCard", agentCard.Name, + "policy", policyName, + "reason", "signature not verified") + return r.Create(ctx, policy) + } + return err + } + + existingPolicy.Spec = policy.Spec + networkPolicyLogger.Info("Updating NetworkPolicy to restrictive for unverified agent", + "agentCard", agentCard.Name, + "policy", policyName) + return r.Update(ctx, existingPolicy) +} + +// 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 from the resolved targetRef or card name + workloadName := agentCard.Name + if agentCard.Status.TargetRef != nil { + workloadName = agentCard.Status.TargetRef.Name + } + 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 { + if err := r.Delete(ctx, policy); err != nil { + 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{ + Name: agentCard.Name, + Namespace: agentCard.Namespace, + }, latest); err != nil { + return err + } + + controllerutil.RemoveFinalizer(latest, NetworkPolicyFinalizer) + return r.Update(ctx, latest) + }); err != nil { + networkPolicyLogger.Error(err, "Failed to remove finalizer from AgentCard") + return ctrl.Result{}, err + } + + networkPolicyLogger.Info("Removed finalizer from AgentCard") + } + + return ctrl.Result{}, nil +} + +// mapWorkloadToAgentCard maps Deployment/StatefulSet events to AgentCard reconcile requests +func (r *AgentCardNetworkPolicyReconciler) mapWorkloadToAgentCard(apiVersion, kind string) handler.MapFunc { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + labels := obj.GetLabels() + if !isAgentWorkload(labels) { + return nil + } + + agentCardList := &agentv1alpha1.AgentCardList{} + if err := r.List(ctx, agentCardList, client.InNamespace(obj.GetNamespace())); err != nil { + networkPolicyLogger.Error(err, "Failed to list AgentCards for mapping") + return nil + } + + var requests []reconcile.Request + for _, agentCard := range agentCardList.Items { + if agentCard.Spec.TargetRef != nil && + agentCard.Spec.TargetRef.Name == obj.GetName() && + 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 + } +} + +// mapAgentToAgentCard maps Agent events to AgentCard reconcile requests +func (r *AgentCardNetworkPolicyReconciler) mapAgentToAgentCard(ctx context.Context, obj client.Object) []reconcile.Request { + agent, ok := obj.(*agentv1alpha1.Agent) + if !ok { + return nil + } + + if agent.Labels == nil || agent.Labels[LabelAgentType] != LabelValueAgent { + return nil + } + + agentCardList := &agentv1alpha1.AgentCardList{} + if err := r.List(ctx, agentCardList, client.InNamespace(agent.Namespace)); err != nil { + networkPolicyLogger.Error(err, "Failed to list AgentCards for mapping") + return nil + } + + var requests []reconcile.Request + for _, agentCard := range agentCardList.Items { + // Check targetRef + if agentCard.Spec.TargetRef != nil && + agentCard.Spec.TargetRef.Name == agent.Name && + agentCard.Spec.TargetRef.Kind == "Agent" { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: agentCard.Name, + Namespace: agentCard.Namespace, + }, + }) + continue + } + // Check selector + if agentCard.Spec.Selector != nil && agent.Labels != nil { + match := true + for key, value := range agentCard.Spec.Selector.MatchLabels { + if agent.Labels[key] != value { + match = false + break + } + } + if match { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: agentCard.Name, + Namespace: agentCard.Namespace, + }, + }) + } + } + } + + return requests +} + +// SetupWithManager sets up the controller with the Manager. +func (r *AgentCardNetworkPolicyReconciler) SetupWithManager(mgr ctrl.Manager) error { + 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")), + builder.WithPredicates(agentLabelPredicate()), + ) + + // Optionally watch legacy Agent CRDs if enabled + if r.EnableLegacyAgentCRD { + controllerBuilder = controllerBuilder.Watches( + &agentv1alpha1.Agent{}, + handler.EnqueueRequestsFromMapFunc(r.mapAgentToAgentCard), + builder.WithPredicates(agentLabelPredicate()), + ) + } + + return controllerBuilder. + Named("AgentCardNetworkPolicy"). + Complete(r) +} + diff --git a/kagenti-operator/internal/controller/agentcardsync_controller.go b/kagenti-operator/internal/controller/agentcardsync_controller.go index fdc3c155..d9a3cd36 100644 --- a/kagenti-operator/internal/controller/agentcardsync_controller.go +++ b/kagenti-operator/internal/controller/agentcardsync_controller.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "strings" + "time" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -38,6 +39,15 @@ var ( syncLogger = ctrl.Log.WithName("controller").WithName("AgentCardSync") ) +// 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, StatefulSets, and legacy Agent CRDs) type AgentCardSyncReconciler struct { @@ -45,6 +55,21 @@ type AgentCardSyncReconciler struct { Scheme *runtime.Scheme // EnableLegacyAgentCRD enables watching legacy Agent CRD resources EnableLegacyAgentCRD bool + // 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 +} + +// 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 } // +kubebuilder:rbac:groups=agent.kagenti.dev,resources=agents,verbs=get;list;watch @@ -70,6 +95,14 @@ func (r *AgentCardSyncReconciler) ReconcileDeployment(ctx context.Context, req c return ctrl.Result{}, nil } + // Skip Deployments owned by the legacy Agent CRD — the Agent reconciler handles those. + // This prevents duplicate AgentCards (one from Agent path, one from Deployment path). + if isOwnedByAgentCRD(deployment) { + syncLogger.V(1).Info("Skipping Deployment owned by Agent CRD", + "deployment", deployment.Name, "namespace", deployment.Namespace) + return ctrl.Result{}, nil + } + // Create or update AgentCard with targetRef gvk := appsv1.SchemeGroupVersion.WithKind("Deployment") return r.ensureAgentCard(ctx, deployment, gvk) @@ -273,10 +306,87 @@ 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, + "workload", obj.GetName(), + "kind", gvk.Kind) + 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", + "workload", obj.GetName(), "kind", gvk.Kind, + "age", time.Since(obj.GetCreationTimestamp().Time).Round(time.Millisecond)) + 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 — either via targetRef or via selector labels. 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. +func (r *AgentCardSyncReconciler) findExistingCardForWorkload(ctx context.Context, obj client.Object, gvk schema.GroupVersionKind) (string, bool) { + cardList := &agentv1alpha1.AgentCardList{} + if err := r.List(ctx, cardList, client.InNamespace(obj.GetNamespace())); err != nil { + syncLogger.Error(err, "Failed to list AgentCards for duplicate check") + return "", false + } + + expectedAPIVersion := gvk.GroupVersion().String() + workloadLabels := obj.GetLabels() + + for i := range cardList.Items { + card := &cardList.Items[i] + + // Check targetRef match + if card.Spec.TargetRef != nil && + card.Spec.TargetRef.APIVersion == expectedAPIVersion && + card.Spec.TargetRef.Kind == gvk.Kind && + card.Spec.TargetRef.Name == obj.GetName() { + return card.Name, true + } + + // Check selector match — the card's selector labels must be a subset of the workload labels + if card.Spec.Selector != nil && len(card.Spec.Selector.MatchLabels) > 0 { + allMatch := true + for k, v := range card.Spec.Selector.MatchLabels { + if workloadLabels[k] != v { + allMatch = false + break + } + } + if allMatch { + return card.Name, true + } + } + } + return "", false +} + +// isOwnedByAgentCRD checks if an object (Deployment/StatefulSet) is owned by the +// legacy Agent CRD. Returns true if any owner reference has kind=Agent in the +// kagenti API group. +func isOwnedByAgentCRD(obj client.Object) bool { + for _, ref := range obj.GetOwnerReferences() { + if ref.Kind == "Agent" && ref.APIVersion == agentv1alpha1.GroupVersion.String() { + return true + } + } + return false +} + // 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", diff --git a/kagenti-operator/internal/controller/agentcardsync_controller_test.go b/kagenti-operator/internal/controller/agentcardsync_controller_test.go index 99366c2b..0369d332 100644 --- a/kagenti-operator/internal/controller/agentcardsync_controller_test.go +++ b/kagenti-operator/internal/controller/agentcardsync_controller_test.go @@ -103,6 +103,7 @@ var _ = Describe("AgentCardSync Controller", func() { Client: k8sClient, Scheme: k8sClient.Scheme(), EnableLegacyAgentCRD: true, + AutoSyncGracePeriod: -1, // disable grace period in tests } _, err := reconciler.ReconcileAgent(ctx, reconcile.Request{ @@ -142,7 +143,6 @@ var _ = Describe("AgentCardSync Controller", func() { Namespace: namespace, Labels: map[string]string{ LabelAgentType: LabelValueAgent, - // No protocol label }, }, Spec: agentv1alpha1.AgentSpec{ @@ -168,6 +168,7 @@ var _ = Describe("AgentCardSync Controller", func() { Client: k8sClient, Scheme: k8sClient.Scheme(), EnableLegacyAgentCRD: true, + AutoSyncGracePeriod: -1, } _, err := reconciler.ReconcileAgent(ctx, reconcile.Request{ @@ -227,6 +228,7 @@ var _ = Describe("AgentCardSync Controller", func() { Client: k8sClient, Scheme: k8sClient.Scheme(), EnableLegacyAgentCRD: true, + AutoSyncGracePeriod: -1, } _, err := reconciler.ReconcileAgent(ctx, reconcile.Request{ @@ -303,6 +305,7 @@ var _ = Describe("AgentCardSync Controller", func() { Client: k8sClient, Scheme: k8sClient.Scheme(), EnableLegacyAgentCRD: true, + AutoSyncGracePeriod: -1, } _, err := reconciler.ReconcileAgent(ctx, reconcile.Request{ @@ -412,8 +415,9 @@ var _ = Describe("AgentCardSync Controller", func() { It("should automatically create an AgentCard with targetRef for Deployment", func() { By("reconciling the Deployment") reconciler := &AgentCardSyncReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AutoSyncGracePeriod: -1, } _, err := reconciler.ReconcileDeployment(ctx, reconcile.Request{ @@ -484,8 +488,9 @@ var _ = Describe("AgentCardSync Controller", func() { By("reconciling the Deployment") reconciler := &AgentCardSyncReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AutoSyncGracePeriod: -1, } _, err := reconciler.ReconcileDeployment(ctx, reconcile.Request{ @@ -585,8 +590,9 @@ var _ = Describe("AgentCardSync Controller", func() { It("should automatically create an AgentCard with targetRef for StatefulSet", func() { By("reconciling the StatefulSet") reconciler := &AgentCardSyncReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AutoSyncGracePeriod: -1, } _, err := reconciler.ReconcileStatefulSet(ctx, reconcile.Request{ @@ -712,8 +718,9 @@ var _ = Describe("AgentCardSync Controller", func() { It("should migrate AgentCard from selector to targetRef", func() { By("reconciling the Deployment") reconciler := &AgentCardSyncReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AutoSyncGracePeriod: -1, } _, err := reconciler.ReconcileDeployment(ctx, reconcile.Request{ diff --git a/kagenti-operator/internal/controller/identity_binding_test.go b/kagenti-operator/internal/controller/identity_binding_test.go index 4970358d..09630644 100644 --- a/kagenti-operator/internal/controller/identity_binding_test.go +++ b/kagenti-operator/internal/controller/identity_binding_test.go @@ -18,6 +18,10 @@ package controller import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "time" . "github.com/onsi/ginkgo/v2" @@ -32,6 +36,7 @@ import ( agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" "github.com/kagenti/operator/internal/distribution" + "github.com/kagenti/operator/internal/signature" ) var _ = Describe("Identity Binding", func() { @@ -44,6 +49,7 @@ var _ = Describe("Identity Binding", func() { const ( agentName = "bind-eval-match-agent" agentCardName = "bind-eval-match-card" + secretName = "bind-eval-match-keys" namespace = "default" trustDomain = "test.local" ) @@ -56,9 +62,24 @@ var _ = Describe("Identity Binding", func() { cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) cleanupResource(ctx, &agentv1alpha1.Agent{}, agentName, namespace) cleanupResource(ctx, &corev1.Service{}, agentName, 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()) + By("creating an Agent with specific service account") agent := &agentv1alpha1.Agent{ ObjectMeta: metav1.ObjectMeta{ @@ -115,8 +136,17 @@ var _ = Describe("Identity Binding", func() { } Expect(k8sClient.Create(ctx, service)).To(Succeed()) - By("creating an AgentCard with matching SPIFFE ID") + 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", + } + 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, @@ -131,7 +161,6 @@ var _ = Describe("Identity Binding", func() { }, }, IdentityBinding: &agentv1alpha1.IdentityBinding{ - TrustDomain: trustDomain, AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{agentv1alpha1.SpiffeID(expectedSpiffeID)}, Strict: false, }, @@ -139,27 +168,30 @@ var _ = Describe("Identity Binding", func() { } Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) - By("reconciling the AgentCard") + 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: &agentv1alpha1.AgentCardData{ - Name: "Test Agent", - Version: "1.0.0", - URL: "http://localhost:8000", - }, - }, - TrustDomain: trustDomain, + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AgentFetcher: &mockFetcher{cardData: cardData}, + RequireSignature: true, + SignatureProvider: provider, } - // First reconcile adds finalizer - _, err := reconciler.Reconcile(ctx, reconcile.Request{ + 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()) - // Second reconcile evaluates binding + By("reconciling again (verifies signature and evaluates binding in one pass)") _, err = reconciler.Reconcile(ctx, reconcile.Request{ NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, }) @@ -187,6 +219,7 @@ var _ = Describe("Identity Binding", func() { const ( agentName = "bind-eval-nomatch-agent" agentCardName = "bind-eval-nomatch-card" + secretName = "bind-eval-nomatch-keys" namespace = "default" trustDomain = "test.local" ) @@ -198,9 +231,24 @@ var _ = Describe("Identity Binding", func() { cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) cleanupResource(ctx, &agentv1alpha1.Agent{}, agentName, namespace) cleanupResource(ctx, &corev1.Service{}, agentName, 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 an Agent") agent := &agentv1alpha1.Agent{ ObjectMeta: metav1.ObjectMeta{ @@ -257,7 +305,18 @@ var _ = Describe("Identity Binding", func() { } Expect(k8sClient.Create(ctx, service)).To(Succeed()) - By("creating an AgentCard with non-matching SPIFFE ID") + 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", + } + 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, @@ -272,7 +331,6 @@ var _ = Describe("Identity Binding", func() { }, }, IdentityBinding: &agentv1alpha1.IdentityBinding{ - TrustDomain: trustDomain, AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{"spiffe://" + trustDomain + "/ns/other/sa/other-sa"}, Strict: false, }, @@ -280,27 +338,30 @@ var _ = Describe("Identity Binding", func() { } Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) - By("reconciling the AgentCard") + 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: &agentv1alpha1.AgentCardData{ - Name: "Test Agent", - Version: "1.0.0", - URL: "http://localhost:8000", - }, - }, - TrustDomain: trustDomain, + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AgentFetcher: &mockFetcher{cardData: cardData}, + RequireSignature: true, + SignatureProvider: provider, } - // First reconcile adds finalizer - _, err := reconciler.Reconcile(ctx, reconcile.Request{ + 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()) - // Second reconcile evaluates binding + 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}, }) @@ -408,7 +469,6 @@ var _ = Describe("Identity Binding", func() { }, }, IdentityBinding: &agentv1alpha1.IdentityBinding{ - TrustDomain: trustDomain, AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{"spiffe://" + trustDomain + "/ns/other/sa/other-sa"}, Strict: true, }, @@ -592,7 +652,6 @@ var _ = Describe("Identity Binding", func() { }, }, IdentityBinding: &agentv1alpha1.IdentityBinding{ - TrustDomain: trustDomain, AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{agentv1alpha1.SpiffeID(expectedSpiffeID)}, Strict: true, }, @@ -707,93 +766,93 @@ var _ = Describe("Identity Binding", func() { }) }) - Context("SPIFFE ID Derivation", func() { - It("should derive expected SPIFFE ID from agent metadata", func() { + Context("SPIFFE ID Source — JWS Protected Header Only", func() { + It("should fail binding when no SPIFFE ID is in the JWS protected header", func() { reconciler := &AgentCardReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - TrustDomain: "my.domain", + Client: k8sClient, + Scheme: k8sClient.Scheme(), } - // Create the agent in the cluster for getWorkloadServiceAccount to fetch - agent := &agentv1alpha1.Agent{ + agentCard := &agentv1alpha1.AgentCard{ ObjectMeta: metav1.ObjectMeta{ - Name: "spiffe-test-agent", + Name: "no-spiffe-card", Namespace: "default", }, - Spec: agentv1alpha1.AgentSpec{ - PodTemplateSpec: &corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - ServiceAccountName: "my-service-account", - Containers: []corev1.Container{ - { - Name: "agent", - Image: "test-image:latest", - }, - }, - }, + Spec: agentv1alpha1.AgentCardSpec{ + IdentityBinding: &agentv1alpha1.IdentityBinding{ + AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{"spiffe://example.com/ns/default/sa/test"}, }, }, } - Expect(k8sClient.Create(ctx, agent)).To(Succeed()) - defer k8sClient.Delete(ctx, agent) + 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()) + }) - workload := &WorkloadInfo{ - Name: agent.Name, - Namespace: agent.Namespace, - Kind: "Agent", - APIVersion: agentv1alpha1.GroupVersion.String(), + It("should bind when JWS SPIFFE ID matches the allowlist", func() { + reconciler := &AgentCardReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), } - sa, err := reconciler.getWorkloadServiceAccount(ctx, workload) - Expect(err).NotTo(HaveOccurred()) - Expect(sa).To(Equal("my-service-account")) + jwsSpiffeID := "spiffe://example.com/ns/default/sa/from-jws" - // Expected SPIFFE ID format: spiffe:///ns//sa/ - expectedSpiffeID := "spiffe://my.domain/ns/default/sa/my-service-account" - actualSpiffeID := "spiffe://" + reconciler.TrustDomain + "/ns/" + workload.Namespace + "/sa/" + sa - Expect(actualSpiffeID).To(Equal(expectedSpiffeID)) + agentCard := &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "jws-spiffe-card", + Namespace: "default", + }, + Spec: agentv1alpha1.AgentCardSpec{ + IdentityBinding: &agentv1alpha1.IdentityBinding{ + AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{agentv1alpha1.SpiffeID(jwsSpiffeID)}, + }, + }, + } + 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) + Expect(result).NotTo(BeNil()) + Expect(result.Bound).To(BeTrue()) }) - It("should use default service account name pattern when not specified", func() { + It("should not trust JWS SPIFFE ID when signature is invalid", func() { reconciler := &AgentCardReconciler{ Client: k8sClient, Scheme: k8sClient.Scheme(), } - // Create the agent in the cluster for getWorkloadServiceAccount to fetch - agent := &agentv1alpha1.Agent{ + jwsSpiffeID := "spiffe://example.com/ns/default/sa/from-jws" + + agentCard := &agentv1alpha1.AgentCard{ ObjectMeta: metav1.ObjectMeta{ - Name: "spiffe-test-agent-default", + Name: "invalid-sig-spiffe-card", Namespace: "default", }, - Spec: agentv1alpha1.AgentSpec{ - PodTemplateSpec: &corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - // No ServiceAccountName specified - Containers: []corev1.Container{ - { - Name: "agent", - Image: "test-image:latest", - }, - }, - }, + Spec: agentv1alpha1.AgentCardSpec{ + IdentityBinding: &agentv1alpha1.IdentityBinding{ + AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{agentv1alpha1.SpiffeID(jwsSpiffeID)}, }, }, } - Expect(k8sClient.Create(ctx, agent)).To(Succeed()) - defer k8sClient.Delete(ctx, agent) - - workload := &WorkloadInfo{ - Name: agent.Name, - Namespace: agent.Namespace, - Kind: "Agent", - APIVersion: agentv1alpha1.GroupVersion.String(), - } - - sa, err := reconciler.getWorkloadServiceAccount(ctx, workload) - Expect(err).NotTo(HaveOccurred()) - Expect(sa).To(Equal("spiffe-test-agent-default-sa")) + 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()) }) }) @@ -865,33 +924,6 @@ var _ = Describe("Identity Binding", func() { }) }) -// Helper function to list AgentCards for an Agent -func listAgentCardsForAgent(ctx context.Context, c client.Client, agent *agentv1alpha1.Agent) ([]*agentv1alpha1.AgentCard, error) { - cards := &agentv1alpha1.AgentCardList{} - if err := c.List(ctx, cards, client.InNamespace(agent.Namespace)); err != nil { - return nil, err - } - - var result []*agentv1alpha1.AgentCard - for i := range cards.Items { - card := &cards.Items[i] - if card.Labels == nil { - continue - } - match := true - for key, value := range card.Spec.Selector.MatchLabels { - if agent.Labels[key] != value { - match = false - break - } - } - if match { - result = append(result, card) - } - } - return result, nil -} - // 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} diff --git a/kagenti-operator/internal/controller/signature_verification_test.go b/kagenti-operator/internal/controller/signature_verification_test.go new file mode 100644 index 00000000..03415e39 --- /dev/null +++ b/kagenti-operator/internal/controller/signature_verification_test.go @@ -0,0 +1,1182 @@ +/* +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 controller + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "sort" + "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" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" + "github.com/kagenti/operator/internal/signature" +) + +var _ = Describe("Signature Verification", func() { + const ( + timeout = time.Second * 10 + interval = time.Millisecond * 250 + ) + + Context("Signed AgentCard — Valid JWS Signature", func() { + const ( + agentName = "sig-valid-agent" + agentCardName = "sig-valid-card" + namespace = "default" + secretName = "sig-valid-keys" + ) + + var ( + rsaPrivKey *rsa.PrivateKey + pubKeyPEM []byte + ) + + ctx := context.Background() + + 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()) + }) + + AfterEach(func() { + By("cleaning up test resources") + cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) + cleanupResource(ctx, &agentv1alpha1.Agent{}, agentName, namespace) + cleanupResource(ctx, &corev1.Service{}, agentName, namespace) + cleanupResource(ctx, &corev1.Secret{}, secretName, namespace) + }) + + It("should set validSignature=true and SignatureVerified condition for a correctly signed card", func() { + By("creating an Agent") + agent := &agentv1alpha1.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: agentName, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/name": agentName, + LabelAgentType: LabelValueAgent, + LabelAgentProtocol: "a2a", + }, + }, + Spec: agentv1alpha1.AgentSpec{ + PodTemplateSpec: &corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "agent", Image: "test-image:latest"}, + }, + }, + }, + ImageSource: agentv1alpha1.ImageSource{Image: ptr.To("test-image:latest")}, + }, + } + Expect(k8sClient.Create(ctx, agent)).To(Succeed()) + Eventually(func() error { + if err := k8sClient.Get(ctx, types.NamespacedName{Name: agentName, Namespace: namespace}, agent); err != nil { + return err + } + agent.Status.DeploymentStatus = &agentv1alpha1.DeploymentStatus{Phase: agentv1alpha1.PhaseReady} + return k8sClient.Status().Update(ctx, agent) + }).Should(Succeed()) + + By("creating a Service for the Agent") + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: agentName, Namespace: namespace}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Name: "http", Port: 8000, Protocol: corev1.ProtocolTCP}}, + Selector: map[string]string{"app.kubernetes.io/name": agentName}, + }, + } + Expect(k8sClient.Create(ctx, service)).To(Succeed()) + + By("creating a signed agent card (JWS format)") + cardData := &agentv1alpha1.AgentCardData{ + Name: "Valid Signed Agent", + Version: "1.0.0", + URL: "http://localhost:8000", + } + jwsSig := buildTestJWS(cardData, rsaPrivKey, "my-signing-key", "") + cardData.Signatures = []agentv1alpha1.AgentCardSignature{jwsSig} + + By("creating an AgentCard CR") + agentCard := &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Name: agentCardName, Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{ + SyncPeriod: "30s", + Selector: &agentv1alpha1.AgentSelector{ + MatchLabels: map[string]string{ + "app.kubernetes.io/name": agentName, + LabelAgentType: LabelValueAgent, + }, + }, + }, + } + 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) + + reconciler := &AgentCardReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AgentFetcher: &mockFetcher{cardData: cardData}, + RequireSignature: true, + SignatureProvider: provider, + SignatureAuditMode: false, + } + + // First reconcile adds finalizer + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + // Second reconcile performs verification + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("verifying validSignature=true") + 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.ValidSignature != nil && *card.Status.ValidSignature + }, timeout, interval).Should(BeTrue()) + + By("verifying SignatureVerified condition is True") + 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()) + Expect(sigCond.Status).To(Equal(metav1.ConditionTrue)) + Expect(sigCond.Reason).To(Equal(ReasonSignatureValid)) + + By("verifying signatureKeyId is set") + Expect(card.Status.SignatureKeyID).To(Equal("my-signing-key")) + + By("verifying Synced condition is True") + syncedCond := findCondition(card.Status.Conditions, "Synced") + Expect(syncedCond).NotTo(BeNil()) + Expect(syncedCond.Status).To(Equal(metav1.ConditionTrue)) + }) + }) + + Context("Unsigned AgentCard — Rejected", func() { + const ( + agentName = "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, &agentv1alpha1.Agent{}, agentName, namespace) + cleanupResource(ctx, &corev1.Service{}, agentName, namespace) + cleanupResource(ctx, &corev1.Secret{}, secretName, namespace) + }) + + It("should set validSignature=false for an unsigned card", func() { + By("creating Agent, Service, and AgentCard") + createAgentWithService(ctx, agentName, namespace) + + agentCard := &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Name: agentCardName, Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{ + SyncPeriod: "30s", + Selector: &agentv1alpha1.AgentSelector{ + MatchLabels: map[string]string{ + "app.kubernetes.io/name": agentName, + LabelAgentType: LabelValueAgent, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) + + By("setting up reconciler with unsigned card data") + cardData := &agentv1alpha1.AgentCardData{ + Name: "Unsigned Agent", + Version: "1.0.0", + URL: "http://localhost:8000", + // 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) + + reconciler := &AgentCardReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AgentFetcher: &mockFetcher{cardData: cardData}, + RequireSignature: true, + SignatureProvider: provider, + SignatureAuditMode: false, + } + + // Reconcile twice (finalizer + verify) + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("verifying validSignature=false") + 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.ValidSignature != nil && !*card.Status.ValidSignature + }, timeout, interval).Should(BeTrue()) + + By("verifying SignatureVerified condition is False") + 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()) + Expect(sigCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(sigCond.Reason).To(Equal(ReasonSignatureInvalid)) + }) + }) + + Context("Wrong-Key JWS Signature — Rejected", func() { + const ( + agentName = "sig-wrongkey-agent" + agentCardName = "sig-wrongkey-card" + namespace = "default" + secretName = "sig-wrongkey-keys" + ) + + ctx := context.Background() + + AfterEach(func() { + cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) + cleanupResource(ctx, &agentv1alpha1.Agent{}, agentName, namespace) + cleanupResource(ctx, &corev1.Service{}, agentName, namespace) + cleanupResource(ctx, &corev1.Secret{}, secretName, namespace) + }) + + It("should set validSignature=false when card is signed with wrong key", func() { + By("generating two different key pairs") + 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 Agent, Service, and AgentCard") + createAgentWithService(ctx, agentName, namespace) + + cardData := &agentv1alpha1.AgentCardData{ + Name: "Wrong Key Agent", + Version: "1.0.0", + URL: "http://localhost:8000", + } + jwsSig := buildTestJWS(cardData, signingKey, "key-1", "") + cardData.Signatures = []agentv1alpha1.AgentCardSignature{jwsSig} + + agentCard := &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Name: agentCardName, Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{ + SyncPeriod: "30s", + Selector: &agentv1alpha1.AgentSelector{ + MatchLabels: map[string]string{ + "app.kubernetes.io/name": agentName, + LabelAgentType: LabelValueAgent, + }, + }, + }, + } + 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) + + reconciler := &AgentCardReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AgentFetcher: &mockFetcher{cardData: cardData}, + RequireSignature: true, + SignatureProvider: provider, + SignatureAuditMode: false, + } + + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("verifying validSignature=false") + card := &agentv1alpha1.AgentCard{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: agentCardName, Namespace: namespace}, card)).To(Succeed()) + Expect(card.Status.ValidSignature).NotTo(BeNil()) + Expect(*card.Status.ValidSignature).To(BeFalse()) + + By("verifying Synced condition is False with InvalidSignature reason") + syncedCond := findCondition(card.Status.Conditions, "Synced") + Expect(syncedCond).NotTo(BeNil()) + Expect(syncedCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(syncedCond.Reason).To(Equal(ReasonSignatureInvalid)) + }) + }) + + Context("Audit Mode — Accept with Warning", func() { + const ( + agentName = "sig-audit-agent" + agentCardName = "sig-audit-card" + namespace = "default" + secretName = "sig-audit-keys" + ) + + ctx := context.Background() + + AfterEach(func() { + cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) + cleanupResource(ctx, &agentv1alpha1.Agent{}, agentName, namespace) + cleanupResource(ctx, &corev1.Service{}, agentName, 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 Agent, Service, and AgentCard") + createAgentWithService(ctx, agentName, namespace) + + cardData := &agentv1alpha1.AgentCardData{ + Name: "Audit Agent", + Version: "1.0.0", + URL: "http://localhost:8000", + // No Signatures + } + + agentCard := &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Name: agentCardName, Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{ + SyncPeriod: "30s", + Selector: &agentv1alpha1.AgentSelector{ + MatchLabels: map[string]string{ + "app.kubernetes.io/name": agentName, + LabelAgentType: LabelValueAgent, + }, + }, + }, + } + 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) + + reconciler := &AgentCardReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AgentFetcher: &mockFetcher{cardData: cardData}, + RequireSignature: true, + SignatureProvider: provider, + SignatureAuditMode: true, + } + + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("verifying card is synced (audit mode allows it)") + Eventually(func() bool { + card := &agentv1alpha1.AgentCard{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: agentCardName, Namespace: namespace}, card); err != nil { + return false + } + syncedCond := findCondition(card.Status.Conditions, "Synced") + return syncedCond != nil && syncedCond.Status == metav1.ConditionTrue + }, timeout, interval).Should(BeTrue()) + + By("verifying SignatureVerified condition mentions audit mode") + 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)) + }) + }) + + Context("No Signature Required — Verification Skipped", func() { + const ( + agentName = "sig-none-agent" + agentCardName = "sig-none-card" + namespace = "default" + ) + + ctx := context.Background() + + AfterEach(func() { + cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) + cleanupResource(ctx, &agentv1alpha1.Agent{}, agentName, namespace) + cleanupResource(ctx, &corev1.Service{}, agentName, namespace) + }) + + It("should sync card without checking signature when RequireSignature=false", func() { + By("creating Agent, Service, and AgentCard") + createAgentWithService(ctx, agentName, namespace) + + cardData := &agentv1alpha1.AgentCardData{ + Name: "No Sig Agent", + Version: "1.0.0", + URL: "http://localhost:8000", + } + + agentCard := &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Name: agentCardName, Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{ + SyncPeriod: "30s", + Selector: &agentv1alpha1.AgentSelector{ + MatchLabels: map[string]string{ + "app.kubernetes.io/name": agentName, + LabelAgentType: LabelValueAgent, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, agentCard)).To(Succeed()) + + By("reconciling WITHOUT signature verification") + reconciler := &AgentCardReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AgentFetcher: &mockFetcher{cardData: cardData}, + RequireSignature: false, + } + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("verifying card is synced and validSignature is nil (not evaluated)") + card := &agentv1alpha1.AgentCard{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: agentCardName, Namespace: namespace}, card)).To(Succeed()) + Expect(card.Status.ValidSignature).To(BeNil()) + + syncedCond := findCondition(card.Status.Conditions, "Synced") + Expect(syncedCond).NotTo(BeNil()) + Expect(syncedCond.Status).To(Equal(metav1.ConditionTrue)) + }) + }) + + Context("Signature Identity Match", func() { + const ( + agentName = "sig-identity-agent" + agentCardName = "sig-identity-card" + namespace = "default" + secretName = "sig-identity-keys" + trustDomain = "test.local" + ) + + ctx := context.Background() + + AfterEach(func() { + cleanupResource(ctx, &agentv1alpha1.AgentCard{}, agentCardName, namespace) + cleanupResource(ctx, &agentv1alpha1.Agent{}, agentName, namespace) + cleanupResource(ctx, &corev1.Service{}, agentName, 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 Agent with service account") + agent := &agentv1alpha1.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: agentName, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/name": agentName, + LabelAgentType: LabelValueAgent, + LabelAgentProtocol: "a2a", + }, + }, + Spec: agentv1alpha1.AgentSpec{ + PodTemplateSpec: &corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + ServiceAccountName: "test-sa", + Containers: []corev1.Container{ + {Name: "agent", Image: "test-image:latest"}, + }, + }, + }, + ImageSource: agentv1alpha1.ImageSource{Image: ptr.To("test-image:latest")}, + }, + } + Expect(k8sClient.Create(ctx, agent)).To(Succeed()) + Eventually(func() error { + if err := k8sClient.Get(ctx, types.NamespacedName{Name: agentName, Namespace: namespace}, agent); err != nil { + return err + } + agent.Status.DeploymentStatus = &agentv1alpha1.DeploymentStatus{Phase: agentv1alpha1.PhaseReady} + return k8sClient.Status().Update(ctx, agent) + }).Should(Succeed()) + + By("creating a Service") + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: agentName, Namespace: namespace}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Name: "http", Port: 8000, Protocol: corev1.ProtocolTCP}}, + Selector: map[string]string{"app.kubernetes.io/name": agentName}, + }, + } + 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: "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) + cardData.Signatures = []agentv1alpha1.AgentCardSignature{jwsSig} + + By("creating AgentCard with both signature verification and identity binding") + agentCard := &agentv1alpha1.AgentCard{ + ObjectMeta: metav1.ObjectMeta{Name: agentCardName, Namespace: namespace}, + Spec: agentv1alpha1.AgentCardSpec{ + SyncPeriod: "30s", + Selector: &agentv1alpha1.AgentSelector{ + MatchLabels: map[string]string{ + "app.kubernetes.io/name": agentName, + LabelAgentType: LabelValueAgent, + }, + }, + IdentityBinding: &agentv1alpha1.IdentityBinding{ + AllowedSpiffeIDs: []agentv1alpha1.SpiffeID{agentv1alpha1.SpiffeID(expectedSpiffeID)}, + }, + }, + } + 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) + + reconciler := &AgentCardReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AgentFetcher: &mockFetcher{cardData: cardData}, + RequireSignature: true, + SignatureProvider: provider, + SignatureAuditMode: false, + } + + // First reconcile adds the finalizer and returns early. + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + // Second reconcile performs verification, binding, and status update. + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("verifying signatureIdentityMatch=true") + 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.SignatureIdentityMatch != nil && *card.Status.SignatureIdentityMatch + }, timeout, interval).Should(BeTrue()) + }) + }) + + Context("Label Propagation — Valid Signature with targetRef Deployment", func() { + const ( + deploymentName = "sig-label-agent" + agentCardName = "sig-label-card" + namespace = "default" + secretName = "sig-label-keys" + ) + + ctx := context.Background() + + 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 propagate signature-verified=true label to Deployment pod template on valid signature", func() { + By("generating key pair and creating secret") + 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) + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: deploymentName, + Namespace: namespace, + Labels: map[string]string{ + "app": deploymentName, + LabelAgentType: LabelValueAgent, + LabelKagentiProtocol: "a2a", + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + 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-image:latest"}, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, deployment)).To(Succeed()) + + By("marking the Deployment as available (simulating real controller)") + Eventually(func() error { + d := &appsv1.Deployment{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: namespace}, d); err != nil { + return err + } + d.Status.Conditions = []appsv1.DeploymentCondition{ + { + Type: appsv1.DeploymentAvailable, + Status: corev1.ConditionTrue, + }, + } + d.Status.Replicas = 1 + d.Status.ReadyReplicas = 1 + return k8sClient.Status().Update(ctx, d) + }).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 (JWS format)") + cardData := &agentv1alpha1.AgentCardData{ + Name: "Label Test Agent", + Version: "1.0.0", + URL: "http://localhost:8000", + } + jwsSig := buildTestJWS(cardData, privKey, "key-1", "") + cardData.Signatures = []agentv1alpha1.AgentCardSignature{jwsSig} + + By("creating AgentCard with targetRef pointing to the Deployment") + 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()) + + 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) + + reconciler := &AgentCardReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AgentFetcher: &mockFetcher{cardData: cardData}, + RequireSignature: true, + SignatureProvider: provider, + SignatureAuditMode: false, + } + + // First reconcile adds finalizer + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + // Second reconcile performs verification + label propagation + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("verifying the signature-verified label is set on the Deployment pod template") + 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")) + }) + }) + + Context("Label Propagation — Invalid Signature removes label from Deployment", func() { + const ( + deploymentName = "sig-label-rm-agent" + agentCardName = "sig-label-rm-card" + namespace = "default" + secretName = "sig-label-rm-keys" + ) + + ctx := context.Background() + + 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 remove signature-verified label when signature becomes invalid", func() { + By("generating two key pairs — signing key and wrong verification key") + 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{ + ObjectMeta: metav1.ObjectMeta{ + Name: deploymentName, + Namespace: namespace, + Labels: map[string]string{ + "app": deploymentName, + LabelAgentType: LabelValueAgent, + LabelKagentiProtocol: "a2a", + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": deploymentName}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": deploymentName, + LabelSignatureVerified: "true", // pre-existing from previous valid state + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "agent", Image: "test-image:latest"}, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, deployment)).To(Succeed()) + + By("marking the Deployment as available") + Eventually(func() error { + d := &appsv1.Deployment{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: namespace}, d); err != nil { + return err + } + d.Status.Conditions = []appsv1.DeploymentCondition{ + { + Type: appsv1.DeploymentAvailable, + Status: corev1.ConditionTrue, + }, + } + d.Status.Replicas = 1 + d.Status.ReadyReplicas = 1 + return k8sClient.Status().Update(ctx, d) + }).Should(Succeed()) + + By("verifying label is initially present") + 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")) + + 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 card signed with wrong key (JWS format)") + cardData := &agentv1alpha1.AgentCardData{ + Name: "Label Removal Agent", + Version: "1.0.0", + URL: "http://localhost:8000", + } + jwsSig := buildTestJWS(cardData, signingKey, "key-1", "") + cardData.Signatures = []agentv1alpha1.AgentCardSignature{jwsSig} + + 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()) + + 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) + + reconciler := &AgentCardReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + AgentFetcher: &mockFetcher{cardData: cardData}, + RequireSignature: true, + SignatureProvider: provider, + SignatureAuditMode: false, + } + + // First reconcile adds finalizer + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + // Second reconcile performs verification — fails — should remove label + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: agentCardName, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("verifying the signature-verified label was REMOVED from the Deployment pod template") + 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 validSignature=false") + card := &agentv1alpha1.AgentCard{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: agentCardName, Namespace: namespace}, card)).To(Succeed()) + Expect(card.Status.ValidSignature).NotTo(BeNil()) + Expect(*card.Status.ValidSignature).To(BeFalse()) + }) + }) +}) + +// --- Test helpers --- + +// generateTestRSAKeyPair generates an RSA key pair for testing. +func generateTestRSAKeyPair() (*rsa.PrivateKey, []byte) { + privKey, _ := rsa.GenerateKey(rand.Reader, 2048) + pubDER, _ := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}) + return privKey, pubPEM +} + +// 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) + 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) + + // Create canonical payload + rawJSON, _ := json.Marshal(cardData) + var cardMap map[string]interface{} + json.Unmarshal(rawJSON, &cardMap) + delete(cardMap, "signatures") + cleanMap := removeEmptyFieldsTest(cardMap) + payload, _ := marshalCanonicalTest(cleanMap) + + // 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[:]) + + return agentv1alpha1.AgentCardSignature{ + Protected: protectedB64, + Signature: base64.RawURLEncoding.EncodeToString(sig), + } +} + +// createAgentWithService creates a minimal Agent and Service for testing. +func createAgentWithService(ctx context.Context, agentName, namespace string) { + agent := &agentv1alpha1.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: agentName, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/name": agentName, + LabelAgentType: LabelValueAgent, + LabelAgentProtocol: "a2a", + }, + }, + Spec: agentv1alpha1.AgentSpec{ + PodTemplateSpec: &corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "agent", Image: "test-image:latest"}, + }, + }, + }, + ImageSource: agentv1alpha1.ImageSource{Image: ptr.To("test-image:latest")}, + }, + } + ExpectWithOffset(1, k8sClient.Create(ctx, agent)).To(Succeed()) + + Eventually(func() error { + if err := k8sClient.Get(ctx, types.NamespacedName{Name: agentName, Namespace: namespace}, agent); err != nil { + return err + } + agent.Status.DeploymentStatus = &agentv1alpha1.DeploymentStatus{Phase: agentv1alpha1.PhaseReady} + return k8sClient.Status().Update(ctx, agent) + }).Should(Succeed()) + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: agentName, Namespace: namespace}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Name: "http", Port: 8000, Protocol: corev1.ProtocolTCP}}, + Selector: map[string]string{"app.kubernetes.io/name": agentName}, + }, + } + ExpectWithOffset(1, k8sClient.Create(ctx, service)).To(Succeed()) +} + +// removeEmptyFieldsTest mirrors the verifier's removeEmptyFields for test signing. +func removeEmptyFieldsTest(m map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + for k, v := range m { + if v == nil { + continue + } + switch val := v.(type) { + case map[string]interface{}: + cleaned := removeEmptyFieldsTest(val) + if len(cleaned) > 0 { + result[k] = cleaned + } + case []interface{}: + if len(val) > 0 { + result[k] = val + } + case string: + if val != "" { + result[k] = val + } + default: + result[k] = v + } + } + return result +} + +// marshalCanonicalTest mirrors the verifier's marshalCanonical for test signing. +func marshalCanonicalTest(data map[string]interface{}) ([]byte, error) { + return json.Marshal(toSortedMap(data)) +} + +// toSortedMap produces a deterministic JSON byte slice with sorted keys. +func toSortedMap(m map[string]interface{}) json.RawMessage { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + result := []byte("{") + for i, k := range keys { + if i > 0 { + result = append(result, ',') + } + keyJSON, _ := json.Marshal(k) + result = append(result, keyJSON...) + result = append(result, ':') + + switch val := m[k].(type) { + case map[string]interface{}: + result = append(result, toSortedMap(val)...) + default: + valJSON, _ := json.Marshal(val) + result = append(result, valJSON...) + } + } + result = append(result, '}') + return result +} diff --git a/kagenti-operator/internal/signature/jwks.go b/kagenti-operator/internal/signature/jwks.go new file mode 100644 index 00000000..783fb335 --- /dev/null +++ b/kagenti-operator/internal/signature/jwks.go @@ -0,0 +1,351 @@ +/* +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" + "sync" + "time" + + agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" + 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 +} + +// 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 + +// 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 + } + + 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 + } + result.Error = fmt.Errorf("no signatures provided") + 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, + Error: err, + 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 + 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 { + jwk = p.findKey(kid) + } + } + + 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, + Error: err, + Details: err.Error(), + }, err +} + +// refreshKeysIfNeeded fetches JWKS keys if cache is stale +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 + } + + return p.fetchKeys(ctx) +} + +// 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(resp.Body) + return fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + 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 +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) + } + + eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E) + if err != nil { + return nil, fmt.Errorf("failed to decode exponent: %w", err) + } + + 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 +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) + } + + publicKey := &ecdsa.PublicKey{ + Curve: curve, + X: new(big.Int).SetBytes(xBytes), + Y: new(big.Int).SetBytes(yBytes), + } + + 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 new file mode 100644 index 00000000..91c12968 --- /dev/null +++ b/kagenti-operator/internal/signature/metrics.go @@ -0,0 +1,80 @@ +/* +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 ( + "github.com/prometheus/client_golang/prometheus" + "sigs.k8s.io/controller-runtime/pkg/metrics" +) + +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", + }, + []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", + 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", + }, + []string{"provider", "error_type"}, + ) +) + +func init() { + // Register custom metrics with the global Prometheus registry + metrics.Registry.MustRegister( + SignatureVerificationTotal, + SignatureVerificationDuration, + SignatureVerificationErrors, + ) +} + +// RecordVerification records a signature verification result +func RecordVerification(provider string, verified bool, auditMode bool) { + result := "failed" + if verified { + result = "success" + } + audit := "false" + if auditMode { + audit = "true" + } + 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 new file mode 100644 index 00000000..747a8d9d --- /dev/null +++ b/kagenti-operator/internal/signature/noop.go @@ -0,0 +1,45 @@ +/* +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 new file mode 100644 index 00000000..f8e67bcd --- /dev/null +++ b/kagenti-operator/internal/signature/provider.go @@ -0,0 +1,91 @@ +/* +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" + "time" + + agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" +) + +// VerificationResult contains the result of signature verification +type VerificationResult struct { + Verified bool + KeyID string + SpiffeID string // SPIFFE ID extracted from the JWS protected header + Error error + Details string +} + +// Provider defines the interface for A2A signature verification. +// Implementations can support Kubernetes Secrets, JWKS servers, or other methods. +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(ctx context.Context, cardData *agentv1alpha1.AgentCardData, signatures []agentv1alpha1.AgentCardSignature) (*VerificationResult, error) + + // Name returns the provider name for logging and metrics + Name() 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" +) + +// Config holds configuration for signature verification providers +type Config struct { + Type ProviderType + + // For secret-based provider + SecretName string + SecretNamespace string + SecretKey string + + // For JWKS provider + JWKSURL string + JWKSCacheTTL time.Duration // How long to cache JWKS keys (default: 5 minutes) + + // Common settings + AuditMode bool // If true, log verification failures but don't block +} + diff --git a/kagenti-operator/internal/signature/provider_test.go b/kagenti-operator/internal/signature/provider_test.go new file mode 100644 index 00000000..25933e2d --- /dev/null +++ b/kagenti-operator/internal/signature/provider_test.go @@ -0,0 +1,179 @@ +/* +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" + "testing" + "time" + + agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" +) + +// --- NewProvider factory tests --- + +func TestNewProvider_NilConfig(t *testing.T) { + _, err := NewProvider(nil) + if err == nil { + t.Error("Expected error for nil config") + } +} + +func TestNewProvider_UnknownType(t *testing.T) { + _, err := NewProvider(&Config{Type: "unknown"}) + if err == nil { + t.Error("Expected error for unknown provider type") + } +} + +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) { + _, err := NewProvider(&Config{ + Type: ProviderTypeSecret, + SecretNamespace: "default", + }) + if err == nil { + t.Error("Expected error when SecretName is empty") + } +} + +func TestNewProvider_Secret_MissingNamespace(t *testing.T) { + _, err := NewProvider(&Config{ + Type: ProviderTypeSecret, + SecretName: "my-secret", + }) + if err == nil { + t.Error("Expected error when SecretNamespace is empty") + } +} + +func TestNewProvider_Secret_Valid(t *testing.T) { + p, err := NewProvider(&Config{ + Type: ProviderTypeSecret, + SecretName: "a2a-keys", + SecretNamespace: "kagenti-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) + } + }) + } +} diff --git a/kagenti-operator/internal/signature/secret.go b/kagenti-operator/internal/signature/secret.go new file mode 100644 index 00000000..52bc9422 --- /dev/null +++ b/kagenti-operator/internal/signature/secret.go @@ -0,0 +1,216 @@ +/* +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. +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, + Error: fmt.Errorf("kubernetes client not initialized"), + 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 + } + result.Error = fmt.Errorf("no signatures provided") + 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, + Error: err, + 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 + 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, + Error: err, + 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 + } + // Also try with common extensions + 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 new file mode 100644 index 00000000..25ccddf9 --- /dev/null +++ b/kagenti-operator/internal/signature/secret_test.go @@ -0,0 +1,428 @@ +/* +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 new file mode 100644 index 00000000..8fc7749f --- /dev/null +++ b/kagenti-operator/internal/signature/verifier.go @@ -0,0 +1,504 @@ +/* +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 ( + "bytes" + "crypto" + "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/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "sort" + + 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) +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"` +} + +// DecodeProtectedHeader decodes a base64url-encoded JWS protected header. +func DecodeProtectedHeader(protected string) (*ProtectedHeader, error) { + headerJSON, err := base64.RawURLEncoding.DecodeString(protected) + if err != nil { + return nil, fmt.Errorf("failed to decode protected header: %w", err) + } + var header ProtectedHeader + if err := json.Unmarshal(headerJSON, &header); err != nil { + return nil, fmt.Errorf("failed to parse protected header: %w", err) + } + 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 { + return "", fmt.Errorf("failed to marshal protected header: %w", err) + } + 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" +func VerifyJWS(cardData *agentv1alpha1.AgentCardData, sig *agentv1alpha1.AgentCardSignature, publicKeyPEM []byte) (*VerificationResult, error) { + if sig == nil { + return &VerificationResult{ + Verified: false, + Error: fmt.Errorf("no signature provided"), + Details: "AgentCard does not contain a signature", + }, nil + } + + // Decode the protected header to extract alg, kid, spiffe_id + header, err := DecodeProtectedHeader(sig.Protected) + if err != nil { + return &VerificationResult{ + Verified: false, + Error: err, + Details: fmt.Sprintf("Failed to decode JWS protected header: %v", err), + }, err + } + + // Reject "none" and unsupported algorithms (RFC 7515 §5.2). + if err := validateAlgorithm(header.Algorithm); err != nil { + return &VerificationResult{ + Verified: false, + Error: err, + Details: fmt.Sprintf("Algorithm validation failed: %v", err), + }, err + } + + // Parse the public key + block, _ := pem.Decode(publicKeyPEM) + if block == nil { + return &VerificationResult{ + Verified: false, + Error: fmt.Errorf("failed to decode PEM block"), + Details: "Invalid PEM format", + }, fmt.Errorf("failed to decode PEM block") + } + + publicKey, err := parsePublicKey(block.Bytes) + if err != nil { + return &VerificationResult{ + Verified: false, + Error: err, + Details: fmt.Sprintf("Failed to parse public key: %v", err), + }, err + } + + // Create canonical payload (card JSON excluding "signatures") + payload, err := createCanonicalCardJSON(cardData) + if err != nil { + return &VerificationResult{ + Verified: false, + Error: err, + Details: fmt.Sprintf("Failed to create canonical JSON payload: %v", err), + }, err + } + + // Construct JWS signing input per RFC 7515: + // ASCII(BASE64URL(UTF8(JWS Protected Header))) || '.' || ASCII(BASE64URL(JWS Payload)) + 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) + if err != nil { + return &VerificationResult{ + Verified: false, + Error: err, + Details: fmt.Sprintf("Hash function lookup failed: %v", err), + }, 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{ + Verified: false, + Error: err, + Details: "Failed to decode JWS signature from base64url", + }, err + } + + // Verify signature; also check alg matches key type to prevent algorithm confusion. + var verified bool + switch pub := publicKey.(type) { + case *rsa.PublicKey: + if !isRSAAlgorithm(header.Algorithm) { + return &VerificationResult{ + Verified: false, + Error: fmt.Errorf("algorithm mismatch: header alg=%q but key is RSA", header.Algorithm), + 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, + }) + } else { + err = rsa.VerifyPKCS1v15(pub, hashFunc, hashed, signatureBytes) + } + verified = (err == nil) + case *ecdsa.PublicKey: + if !isECDSAAlgorithm(header.Algorithm) { + return &VerificationResult{ + Verified: false, + Error: fmt.Errorf("algorithm mismatch: header alg=%q but key is ECDSA", header.Algorithm), + 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, + Error: err, + 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. + verified = verifyECDSARaw(pub, hashed, signatureBytes) + if !verified { + // Fallback: try ASN.1 DER encoding for backward compatibility + verified = ecdsa.VerifyASN1(pub, hashed, signatureBytes) + } + default: + return &VerificationResult{ + Verified: false, + Error: fmt.Errorf("unsupported key type"), + Details: "Public key type not supported (expected RSA or ECDSA)", + }, fmt.Errorf("unsupported key type") + } + + result := &VerificationResult{ + Verified: verified, + KeyID: header.KeyID, + SpiffeID: header.SpiffeID, + } + + if !verified { + result.Details = "JWS signature verification failed" + result.Error = fmt.Errorf("JWS signature verification failed") + } else { + result.Details = fmt.Sprintf("JWS signature verified successfully (alg=%s, kid=%s)", header.Algorithm, header.KeyID) + } + + 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. +func verifyECDSARaw(pub *ecdsa.PublicKey, hash, sig []byte) bool { + keySize := curveByteSize(pub.Curve) + if len(sig) != 2*keySize { + return false + } + + r := new(big.Int).SetBytes(sig[:keySize]) + s := new(big.Int).SetBytes(sig[keySize:]) + 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 +} + +// 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") + } + if alg == "none" { + return fmt.Errorf("JWS algorithm 'none' is not permitted — signatures must be cryptographically verified") + } + if !supportedAlgorithms[alg] { + return fmt.Errorf("unsupported JWS algorithm %q (supported: RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512)", alg) + } + 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": + return true + } + 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": + return true + } + return false +} + +// isPSSAlgorithm returns true if the JWS algorithm uses RSA-PSS padding. +func isPSSAlgorithm(alg string) bool { + switch alg { + case "PS256", "PS384", "PS512": + return true + } + 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) { + switch alg { + case "RS256", "PS256", "ES256": + return crypto.SHA256, nil + case "RS384", "PS384", "ES384": + return crypto.SHA384, nil + case "RS512", "PS512", "ES512": + return crypto.SHA512, nil + default: + return 0, fmt.Errorf("no hash function for algorithm %q", alg) + } +} + +// 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 +func validateECDSACurve(pub *ecdsa.PublicKey, alg string) error { + var expectedCurve elliptic.Curve + switch alg { + case "ES256": + expectedCurve = elliptic.P256() + case "ES384": + expectedCurve = elliptic.P384() + case "ES512": + expectedCurve = elliptic.P521() + default: + return fmt.Errorf("unknown ECDSA algorithm %q", alg) + } + if pub.Curve.Params().Name != expectedCurve.Params().Name { + return fmt.Errorf("curve mismatch: algorithm %s requires %s but key uses %s", + alg, expectedCurve.Params().Name, pub.Curve.Params().Name) + } + return nil +} + +// parsePublicKey parses a public key from DER format +func parsePublicKey(derBytes []byte) (crypto.PublicKey, error) { + // Try parsing as PKIX first + if key, err := x509.ParsePKIXPublicKey(derBytes); err == nil { + return key, nil + } + + // Try parsing as PKCS1 RSA public key + if key, err := x509.ParsePKCS1PublicKey(derBytes); err == nil { + return key, nil + } + + return nil, fmt.Errorf("failed to parse public key") +} + +// 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. +func removeEmptyFields(m map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + for k, v := range m { + if v == nil { + continue + } + switch val := v.(type) { + case map[string]interface{}: + cleaned := removeEmptyFields(val) + if len(cleaned) > 0 { + result[k] = cleaned + } + case []interface{}: + if len(val) > 0 { + result[k] = val + } + case string: + if val != "" { + result[k] = val + } + default: + 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 + + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + keyJSON, err := json.Marshal(k) + if err != nil { + return nil, err + } + buf.Write(keyJSON) + buf.WriteByte(':') + valueJSON, err := marshalValue(data[k]) + if err != nil { + return nil, err + } + buf.Write(valueJSON) + } + buf.WriteByte('}') + + 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{}: + return marshalCanonical(val) + case nil: + return []byte("null"), nil + case bool: + if val { + return []byte("true"), nil + } + return []byte("false"), nil + case string: + return json.Marshal(val) + case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return json.Marshal(val) + case []interface{}: + return marshalArray(val) + default: + genericValue, err := toGenericValue(val) + if err != nil { + return nil, err + } + return marshalValue(genericValue) + } +} + +// toGenericValue converts any value to a generic JSON-compatible type. +func toGenericValue(v interface{}) (interface{}, error) { + jsonBytes, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("failed to marshal value: %w", err) + } + var generic interface{} + if err := json.Unmarshal(jsonBytes, &generic); err != nil { + return nil, fmt.Errorf("failed to unmarshal to generic: %w", err) + } + 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('[') + for i, item := range arr { + if i > 0 { + buf.WriteByte(',') + } + itemJSON, err := marshalValue(item) + if err != nil { + return nil, err + } + buf.Write(itemJSON) + } + buf.WriteByte(']') + return buf.Bytes(), nil +} diff --git a/kagenti-operator/internal/signature/verifier_test.go b/kagenti-operator/internal/signature/verifier_test.go new file mode 100644 index 00000000..4fabcdb2 --- /dev/null +++ b/kagenti-operator/internal/signature/verifier_test.go @@ -0,0 +1,881 @@ +/* +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 ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + _ "crypto/sha512" // Register SHA-384 and SHA-512 for hashForAlgorithm tests + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "testing" + + agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" +) + +// --- Test helpers --- + +// generateRSAKeyPair generates an RSA key pair and returns PEM-encoded public key. +func generateRSAKeyPair(t *testing.T) (*rsa.PrivateKey, []byte) { + t.Helper() + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate RSA key: %v", err) + } + pubKeyDER, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + t.Fatalf("Failed to marshal RSA public key: %v", err) + } + pubKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubKeyDER}) + return privKey, pubKeyPEM +} + +// generateECDSAKeyPair generates an ECDSA key pair and returns PEM-encoded public key. +func generateECDSAKeyPair(t *testing.T) (*ecdsa.PrivateKey, []byte) { + t.Helper() + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("Failed to generate ECDSA key: %v", err) + } + pubKeyDER, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + t.Fatalf("Failed to marshal ECDSA public key: %v", err) + } + pubKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubKeyDER}) + return privKey, pubKeyPEM +} + +// 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 { + 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) + if err != nil { + t.Fatalf("Failed to create canonical JSON: %v", err) + } + payloadB64 := base64.RawURLEncoding.EncodeToString(payload) + signingInput := []byte(protectedB64 + "." + payloadB64) + + hash := sha256.Sum256(signingInput) + sigBytes, err := rsa.SignPKCS1v15(rand.Reader, privKey, crypto.SHA256, hash[:]) + if err != nil { + t.Fatalf("Failed to sign: %v", err) + } + + return agentv1alpha1.AgentCardSignature{ + Protected: protectedB64, + Signature: base64.RawURLEncoding.EncodeToString(sigBytes), + } +} + +// buildJWSSignatureECDSA creates a JWS signature using ECDSA (ASN.1 DER, for fallback compat). +func buildJWSSignatureECDSA(t *testing.T, cardData *agentv1alpha1.AgentCardData, privKey *ecdsa.PrivateKey, kid string) agentv1alpha1.AgentCardSignature { + t.Helper() + header := &ProtectedHeader{ + Algorithm: "ES256", + Type: "JOSE", + KeyID: kid, + } + protectedB64, err := EncodeProtectedHeader(header) + if err != nil { + t.Fatalf("Failed to encode protected header: %v", err) + } + + 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) + + hash := sha256.Sum256(signingInput) + sigBytes, err := ecdsa.SignASN1(rand.Reader, privKey, hash[:]) + if err != nil { + t.Fatalf("Failed to sign: %v", err) + } + + return agentv1alpha1.AgentCardSignature{ + Protected: protectedB64, + Signature: base64.RawURLEncoding.EncodeToString(sigBytes), + } +} + +// newCardData creates a simple AgentCardData for testing. +func newCardData(name, url, version string) *agentv1alpha1.AgentCardData { + return &agentv1alpha1.AgentCardData{ + Name: name, + URL: url, + Version: version, + } +} + +// --- ProtectedHeader tests --- + +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 { + t.Fatalf("EncodeProtectedHeader failed: %v", err) + } + + decoded, err := DecodeProtectedHeader(encoded) + if err != nil { + t.Fatalf("DecodeProtectedHeader failed: %v", err) + } + if decoded.Algorithm != "RS256" { + t.Errorf("Expected alg=RS256, got %s", decoded.Algorithm) + } + 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) { + _, err := DecodeProtectedHeader("not-valid-base64!!!") + if err == nil { + t.Error("Expected error for invalid base64url") + } +} + +func TestDecodeProtectedHeader_InvalidJSON(t *testing.T) { + encoded := base64.RawURLEncoding.EncodeToString([]byte("not json")) + _, err := DecodeProtectedHeader(encoded) + if err == nil { + t.Error("Expected error for invalid JSON") + } +} + +// --- Canonical JSON tests --- + +func TestCanonicalJSON_SortedKeys(t *testing.T) { + cardData := &agentv1alpha1.AgentCardData{ + Name: "Test Agent", + URL: "http://localhost:8000", + Version: "1.0.0", + } + + canonical, err := createCanonicalCardJSON(cardData) + if err != nil { + t.Fatalf("createCanonicalCardJSON failed: %v", err) + } + + // Verify it's valid JSON + var parsed map[string]interface{} + if err := json.Unmarshal(canonical, &parsed); err != nil { + t.Fatalf("Canonical JSON is not valid JSON: %v", err) + } + + // Verify no structural whitespace (newlines/tabs) + for _, b := range canonical { + if b == '\n' || b == '\t' { + t.Fatalf("Canonical JSON should not contain newlines or tabs: %s", canonical) + } + } + + // Verify keys are sorted + got := string(canonical) + nameIdx := indexOf(got, `"name"`) + urlIdx := indexOf(got, `"url"`) + versionIdx := indexOf(got, `"version"`) + + if nameIdx >= urlIdx || urlIdx >= versionIdx { + t.Errorf("Keys not sorted: name@%d, url@%d, version@%d in %s", nameIdx, urlIdx, versionIdx, got) + } +} + +func TestCanonicalJSON_ExcludesSignatures(t *testing.T) { + cardData := &agentv1alpha1.AgentCardData{ + Name: "Test Agent", + Version: "1.0.0", + Signatures: []agentv1alpha1.AgentCardSignature{ + {Protected: "abc", Signature: "should-be-excluded"}, + }, + } + + canonical, err := createCanonicalCardJSON(cardData) + if err != nil { + t.Fatalf("createCanonicalCardJSON failed: %v", err) + } + + got := string(canonical) + if indexOf(got, "signatures") >= 0 { + t.Errorf("Canonical JSON should NOT contain 'signatures' field: %s", got) + } + if indexOf(got, "should-be-excluded") >= 0 { + t.Errorf("Canonical JSON should NOT contain signature value: %s", got) + } +} + +func TestCanonicalJSON_ExcludesEmptyFields(t *testing.T) { + cardData := &agentv1alpha1.AgentCardData{ + Name: "Test Agent", + Version: "1.0.0", + } + + canonical, err := createCanonicalCardJSON(cardData) + if err != nil { + t.Fatalf("createCanonicalCardJSON failed: %v", err) + } + + got := string(canonical) + if indexOf(got, `"url"`) >= 0 { + t.Errorf("Canonical JSON should not include empty 'url' field: %s", got) + } + if indexOf(got, `"description"`) >= 0 { + t.Errorf("Canonical JSON should not include empty 'description' field: %s", got) + } +} + +func TestCanonicalJSON_Deterministic(t *testing.T) { + cardData := &agentv1alpha1.AgentCardData{ + Name: "Agent", + Description: "A test agent", + Version: "2.0.0", + URL: "http://localhost:9000", + DefaultInputModes: []string{"text/plain", "application/json"}, + DefaultOutputModes: []string{"application/json"}, + } + + first, err := createCanonicalCardJSON(cardData) + if err != nil { + t.Fatalf("First call failed: %v", err) + } + + for i := 0; i < 10; i++ { + again, err := createCanonicalCardJSON(cardData) + if err != nil { + t.Fatalf("Call %d failed: %v", i, err) + } + if string(first) != string(again) { + t.Fatalf("Canonical JSON is non-deterministic:\n first: %s\n got: %s", first, again) + } + } +} + +func TestCanonicalJSON_NestedCapabilities(t *testing.T) { + streaming := true + push := false + cardData := &agentv1alpha1.AgentCardData{ + Name: "Agent", + Capabilities: &agentv1alpha1.AgentCapabilities{ + Streaming: &streaming, + PushNotifications: &push, + }, + } + + canonical, err := createCanonicalCardJSON(cardData) + if err != nil { + t.Fatalf("createCanonicalCardJSON failed: %v", err) + } + + got := string(canonical) + pushIdx := indexOf(got, `"pushNotifications"`) + streamIdx := indexOf(got, `"streaming"`) + if pushIdx < 0 || streamIdx < 0 { + t.Fatalf("Missing expected nested keys in: %s", got) + } + if pushIdx >= streamIdx { + t.Errorf("Nested keys not sorted: pushNotifications@%d >= streaming@%d in %s", pushIdx, streamIdx, got) + } +} + +// --- JWS RSA signature verification tests --- + +func TestVerifyJWS_RSA_ValidSignature(t *testing.T) { + privKey, pubKeyPEM := generateRSAKeyPair(t) + cardData := newCardData("Weather Agent", "http://weather:8000", "1.0.0") + jwsSig := buildJWSSignature(t, cardData, privKey, "test-key", "") + + result, err := VerifyJWS(cardData, &jwsSig, pubKeyPEM) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !result.Verified { + t.Errorf("Expected verified=true, got false. Details: %s", result.Details) + } + if result.KeyID != "test-key" { + t.Errorf("Expected keyID=test-key, got %s", result.KeyID) + } +} + +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) + + cardData := newCardData("Agent", "http://agent:8000", "1.0.0") + jwsSig := buildJWSSignature(t, cardData, privKey, "wrong-key", "") + + result, err := VerifyJWS(cardData, &jwsSig, wrongPubKeyPEM) + if err != nil { + t.Fatalf("Unexpected error (should return result with Verified=false): %v", err) + } + if result.Verified { + t.Error("Expected verified=false for wrong key, got true") + } +} + +func TestVerifyJWS_RSA_TamperedCard(t *testing.T) { + privKey, pubKeyPEM := generateRSAKeyPair(t) + + original := newCardData("Agent", "http://agent:8000", "1.0.0") + jwsSig := buildJWSSignature(t, original, privKey, "key-1", "") + + // Tamper with card data after signing + tampered := newCardData("Agent", "http://evil:8000", "1.0.0") + + result, err := VerifyJWS(tampered, &jwsSig, pubKeyPEM) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result.Verified { + t.Error("Expected verified=false for tampered card, got true") + } +} + +// --- JWS ECDSA signature verification tests --- + +func TestVerifyJWS_ECDSA_ValidSignature(t *testing.T) { + privKey, pubKeyPEM := generateECDSAKeyPair(t) + cardData := newCardData("ECDSA Agent", "http://ecdsa:8000", "1.0.0") + jwsSig := buildJWSSignatureECDSA(t, cardData, privKey, "ecdsa-key") + + result, err := VerifyJWS(cardData, &jwsSig, pubKeyPEM) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !result.Verified { + t.Errorf("Expected verified=true, got false. Details: %s", result.Details) + } +} + +func TestVerifyJWS_ECDSA_WrongKey(t *testing.T) { + privKey, _ := generateECDSAKeyPair(t) + _, wrongPubKeyPEM := generateECDSAKeyPair(t) + + cardData := newCardData("Agent", "http://agent:8000", "1.0.0") + jwsSig := buildJWSSignatureECDSA(t, cardData, privKey, "wrong-key") + + result, err := VerifyJWS(cardData, &jwsSig, wrongPubKeyPEM) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result.Verified { + t.Error("Expected verified=false for wrong key, got true") + } +} + +// --- Edge case tests --- + +func TestVerifyJWS_NilSignature(t *testing.T) { + _, pubKeyPEM := generateRSAKeyPair(t) + cardData := newCardData("Agent", "http://agent:8000", "1.0.0") + + result, err := VerifyJWS(cardData, nil, pubKeyPEM) + if err != nil { + t.Fatalf("Unexpected error for nil signature: %v", err) + } + if result.Verified { + t.Error("Expected verified=false for nil signature, got true") + } + if indexOf(result.Details, "does not contain a signature") < 0 { + t.Errorf("Expected details to mention missing signature, got: %s", result.Details) + } +} + +func TestVerifyJWS_InvalidProtectedHeader(t *testing.T) { + _, pubKeyPEM := generateRSAKeyPair(t) + cardData := newCardData("Agent", "http://agent:8000", "1.0.0") + + sig := &agentv1alpha1.AgentCardSignature{ + Protected: "not-valid-base64!!!", + Signature: "fake", + } + + _, err := VerifyJWS(cardData, sig, pubKeyPEM) + if err == nil { + t.Error("Expected error for invalid protected header") + } +} + +func TestVerifyJWS_InvalidPEM(t *testing.T) { + privKey, _ := generateRSAKeyPair(t) + cardData := newCardData("Agent", "http://agent:8000", "1.0.0") + jwsSig := buildJWSSignature(t, cardData, privKey, "key-1", "") + + invalidPEM := []byte("not a PEM block") + result, err := VerifyJWS(cardData, &jwsSig, invalidPEM) + if err == nil { + t.Error("Expected error for invalid PEM") + } + if result.Verified { + t.Error("Expected verified=false for invalid PEM") + } +} + +func TestVerifyJWS_InvalidSignatureBase64(t *testing.T) { + _, pubKeyPEM := generateRSAKeyPair(t) + cardData := newCardData("Agent", "http://agent:8000", "1.0.0") + + header := &ProtectedHeader{Algorithm: "RS256", KeyID: "k"} + protectedB64, _ := EncodeProtectedHeader(header) + + sig := &agentv1alpha1.AgentCardSignature{ + Protected: protectedB64, + Signature: "not-valid-base64!!!", + } + + _, err := VerifyJWS(cardData, sig, pubKeyPEM) + if err == nil { + t.Error("Expected error for invalid base64url signature") + } +} + +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) { + ecPriv, _ := generateECDSAKeyPair(t) + _, rsaPubPEM := generateRSAKeyPair(t) + + cardData := newCardData("Agent", "http://agent:8000", "1.0.0") + jwsSig := buildJWSSignatureECDSA(t, cardData, ecPriv, "ec-key") + + // Try verifying ECDSA signature with RSA public key — should fail with algorithm mismatch + result, err := VerifyJWS(cardData, &jwsSig, rsaPubPEM) + if err == nil { + t.Fatal("Expected algorithm mismatch error when using RSA key with ES256 header") + } + if result.Verified { + t.Error("Expected verified=false when using RSA key to verify ECDSA signature") + } + if indexOf(err.Error(), "algorithm mismatch") == -1 { + t.Errorf("Expected 'algorithm mismatch' in error, got: %v", err) + } +} + +// TestVerifyJWS_AlgNone_Rejected verifies that "alg: none" is explicitly rejected. +// This prevents CVE-2015-9235 (the classic JWS "alg: none" attack). +func TestVerifyJWS_AlgNone_Rejected(t *testing.T) { + _, rsaPubPEM := generateRSAKeyPair(t) + cardData := newCardData("Agent", "http://agent:8000", "1.0.0") + + // Craft a signature with alg: "none" + header := &ProtectedHeader{ + Algorithm: "none", + Type: "JOSE", + KeyID: "attack-key", + } + headerJSON, _ := json.Marshal(header) + protectedB64 := base64.RawURLEncoding.EncodeToString(headerJSON) + + sig := agentv1alpha1.AgentCardSignature{ + Protected: protectedB64, + Signature: base64.RawURLEncoding.EncodeToString([]byte("fake-signature")), + } + + result, err := VerifyJWS(cardData, &sig, rsaPubPEM) + if err == nil { + t.Fatal("Expected error when alg is 'none'") + } + if result.Verified { + t.Error("Expected verified=false when alg is 'none'") + } + if indexOf(err.Error(), "not permitted") == -1 { + t.Errorf("Expected 'not permitted' in error, got: %v", err) + } +} + +// TestVerifyJWS_EmptyAlg_Rejected verifies that missing alg is rejected. +func TestVerifyJWS_EmptyAlg_Rejected(t *testing.T) { + _, rsaPubPEM := generateRSAKeyPair(t) + cardData := newCardData("Agent", "http://agent:8000", "1.0.0") + + // Craft a signature with empty alg + header := &ProtectedHeader{ + Type: "JOSE", + KeyID: "some-key", + } + headerJSON, _ := json.Marshal(header) + protectedB64 := base64.RawURLEncoding.EncodeToString(headerJSON) + + sig := agentv1alpha1.AgentCardSignature{ + Protected: protectedB64, + Signature: base64.RawURLEncoding.EncodeToString([]byte("fake-signature")), + } + + result, err := VerifyJWS(cardData, &sig, rsaPubPEM) + if err == nil { + t.Fatal("Expected error when alg is empty") + } + if result.Verified { + t.Error("Expected verified=false when alg is empty") + } +} + +// TestVerifyJWS_UnsupportedAlg_Rejected verifies that unknown algorithms are rejected. +func TestVerifyJWS_UnsupportedAlg_Rejected(t *testing.T) { + _, rsaPubPEM := generateRSAKeyPair(t) + cardData := newCardData("Agent", "http://agent:8000", "1.0.0") + + header := &ProtectedHeader{ + Algorithm: "HS256", // HMAC — not supported (and dangerous if accepted with asymmetric keys) + Type: "JOSE", + KeyID: "some-key", + } + headerJSON, _ := json.Marshal(header) + protectedB64 := base64.RawURLEncoding.EncodeToString(headerJSON) + + sig := agentv1alpha1.AgentCardSignature{ + Protected: protectedB64, + Signature: base64.RawURLEncoding.EncodeToString([]byte("fake-signature")), + } + + result, err := VerifyJWS(cardData, &sig, rsaPubPEM) + if err == nil { + t.Fatal("Expected error when alg is unsupported (HS256)") + } + if result.Verified { + t.Error("Expected verified=false when alg is unsupported") + } +} + +// --- Generic test helpers for multi-algorithm testing --- + +// generateECDSAKeyPairWithCurve generates an ECDSA key pair on the given curve. +func generateECDSAKeyPairWithCurve(t *testing.T, curve elliptic.Curve) (*ecdsa.PrivateKey, []byte) { + t.Helper() + privKey, err := ecdsa.GenerateKey(curve, rand.Reader) + if err != nil { + t.Fatalf("Failed to generate ECDSA key on %s: %v", curve.Params().Name, err) + } + pubKeyDER, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + t.Fatalf("Failed to marshal ECDSA public key: %v", err) + } + pubKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubKeyDER}) + return privKey, pubKeyPEM +} + +// buildJWSSignatureRSAGeneric creates a JWS signature using the specified RSA algorithm and hash. +// Works for RS256/RS384/RS512 (PKCS#1 v1.5) and PS256/PS384/PS512 (RSA-PSS). +func buildJWSSignatureRSAGeneric(t *testing.T, cardData *agentv1alpha1.AgentCardData, privKey *rsa.PrivateKey, kid, alg string) agentv1alpha1.AgentCardSignature { + t.Helper() + header := &ProtectedHeader{ + Algorithm: alg, + Type: "JOSE", + KeyID: kid, + } + protectedB64, err := EncodeProtectedHeader(header) + if err != nil { + t.Fatalf("Failed to encode protected header: %v", err) + } + + 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) + if err != nil { + t.Fatalf("hashForAlgorithm(%s) failed: %v", alg, err) + } + hasher := hashFunc.New() + hasher.Write(signingInput) + hashed := hasher.Sum(nil) + + var sigBytes []byte + if isPSSAlgorithm(alg) { + sigBytes, err = rsa.SignPSS(rand.Reader, privKey, hashFunc, hashed, &rsa.PSSOptions{ + SaltLength: rsa.PSSSaltLengthEqualsHash, + }) + } else { + sigBytes, err = rsa.SignPKCS1v15(rand.Reader, privKey, hashFunc, hashed) + } + if err != nil { + t.Fatalf("Failed to sign with %s: %v", alg, err) + } + + return agentv1alpha1.AgentCardSignature{ + Protected: protectedB64, + Signature: base64.RawURLEncoding.EncodeToString(sigBytes), + } +} + +// buildJWSSignatureECDSAGeneric creates a JWS signature using the specified ECDSA algorithm. +// Uses ASN.1 DER encoding (for backward-compat fallback path). +func buildJWSSignatureECDSAGeneric(t *testing.T, cardData *agentv1alpha1.AgentCardData, privKey *ecdsa.PrivateKey, kid, alg string) agentv1alpha1.AgentCardSignature { + t.Helper() + header := &ProtectedHeader{ + Algorithm: alg, + Type: "JOSE", + KeyID: kid, + } + protectedB64, err := EncodeProtectedHeader(header) + if err != nil { + t.Fatalf("Failed to encode protected header: %v", err) + } + + 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) + if err != nil { + t.Fatalf("hashForAlgorithm(%s) failed: %v", alg, err) + } + hasher := hashFunc.New() + hasher.Write(signingInput) + hashed := hasher.Sum(nil) + + sigBytes, err := ecdsa.SignASN1(rand.Reader, privKey, hashed) + if err != nil { + t.Fatalf("Failed to sign with %s: %v", alg, err) + } + + return agentv1alpha1.AgentCardSignature{ + Protected: protectedB64, + Signature: base64.RawURLEncoding.EncodeToString(sigBytes), + } +} + +// --- RSA-PSS algorithm tests --- + +func TestVerifyJWS_PS256_ValidSignature(t *testing.T) { + privKey, pubKeyPEM := generateRSAKeyPair(t) + cardData := newCardData("PSS Agent", "http://pss:8000", "1.0.0") + jwsSig := buildJWSSignatureRSAGeneric(t, cardData, privKey, "pss-key", "PS256") + + result, err := VerifyJWS(cardData, &jwsSig, pubKeyPEM) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !result.Verified { + t.Errorf("Expected verified=true for PS256, got false. Details: %s", result.Details) + } +} + +func TestVerifyJWS_PS384_ValidSignature(t *testing.T) { + privKey, pubKeyPEM := generateRSAKeyPair(t) + cardData := newCardData("PSS Agent", "http://pss:8000", "1.0.0") + jwsSig := buildJWSSignatureRSAGeneric(t, cardData, privKey, "pss-key", "PS384") + + result, err := VerifyJWS(cardData, &jwsSig, pubKeyPEM) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !result.Verified { + t.Errorf("Expected verified=true for PS384, got false. Details: %s", result.Details) + } +} + +func TestVerifyJWS_PS512_ValidSignature(t *testing.T) { + privKey, pubKeyPEM := generateRSAKeyPair(t) + cardData := newCardData("PSS Agent", "http://pss:8000", "1.0.0") + jwsSig := buildJWSSignatureRSAGeneric(t, cardData, privKey, "pss-key", "PS512") + + result, err := VerifyJWS(cardData, &jwsSig, pubKeyPEM) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !result.Verified { + t.Errorf("Expected verified=true for PS512, got false. Details: %s", result.Details) + } +} + +func TestVerifyJWS_PSS_WrongKey(t *testing.T) { + privKey, _ := generateRSAKeyPair(t) + _, wrongPubKeyPEM := generateRSAKeyPair(t) + + cardData := newCardData("Agent", "http://agent:8000", "1.0.0") + jwsSig := buildJWSSignatureRSAGeneric(t, cardData, privKey, "wrong-key", "PS256") + + result, err := VerifyJWS(cardData, &jwsSig, wrongPubKeyPEM) + if err != nil { + t.Fatalf("Unexpected error (should return result with Verified=false): %v", err) + } + if result.Verified { + t.Error("Expected verified=false for PSS wrong key, got true") + } +} + +// --- RS384/RS512 algorithm tests --- + +func TestVerifyJWS_RS384_ValidSignature(t *testing.T) { + privKey, pubKeyPEM := generateRSAKeyPair(t) + cardData := newCardData("RS384 Agent", "http://rs384:8000", "1.0.0") + jwsSig := buildJWSSignatureRSAGeneric(t, cardData, privKey, "rs384-key", "RS384") + + result, err := VerifyJWS(cardData, &jwsSig, pubKeyPEM) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !result.Verified { + t.Errorf("Expected verified=true for RS384, got false. Details: %s", result.Details) + } +} + +func TestVerifyJWS_RS512_ValidSignature(t *testing.T) { + privKey, pubKeyPEM := generateRSAKeyPair(t) + cardData := newCardData("RS512 Agent", "http://rs512:8000", "1.0.0") + jwsSig := buildJWSSignatureRSAGeneric(t, cardData, privKey, "rs512-key", "RS512") + + result, err := VerifyJWS(cardData, &jwsSig, pubKeyPEM) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !result.Verified { + t.Errorf("Expected verified=true for RS512, got false. Details: %s", result.Details) + } +} + +// --- ECDSA P-384 / P-521 algorithm tests --- + +func TestVerifyJWS_ES384_ValidSignature(t *testing.T) { + privKey, pubKeyPEM := generateECDSAKeyPairWithCurve(t, elliptic.P384()) + cardData := newCardData("ES384 Agent", "http://es384:8000", "1.0.0") + jwsSig := buildJWSSignatureECDSAGeneric(t, cardData, privKey, "es384-key", "ES384") + + result, err := VerifyJWS(cardData, &jwsSig, pubKeyPEM) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !result.Verified { + t.Errorf("Expected verified=true for ES384, got false. Details: %s", result.Details) + } +} + +func TestVerifyJWS_ES512_ValidSignature(t *testing.T) { + privKey, pubKeyPEM := generateECDSAKeyPairWithCurve(t, elliptic.P521()) + cardData := newCardData("ES512 Agent", "http://es512:8000", "1.0.0") + jwsSig := buildJWSSignatureECDSAGeneric(t, cardData, privKey, "es512-key", "ES512") + + result, err := VerifyJWS(cardData, &jwsSig, pubKeyPEM) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !result.Verified { + t.Errorf("Expected verified=true for ES512, got false. Details: %s", result.Details) + } +} + +// --- ECDSA curve mismatch test --- + +func TestVerifyJWS_ECDSA_CurveMismatch(t *testing.T) { + // Generate P-384 key but claim ES256 (which requires P-256) + privKey, pubKeyPEM := generateECDSAKeyPairWithCurve(t, elliptic.P384()) + cardData := newCardData("Agent", "http://agent:8000", "1.0.0") + + // Sign with ES384 (correct for P-384 key) but change the header to ES256 + header := &ProtectedHeader{Algorithm: "ES256", Type: "JOSE", KeyID: "mismatch-key"} + protectedB64, _ := EncodeProtectedHeader(header) + + payload, _ := createCanonicalCardJSON(cardData) + payloadB64 := base64.RawURLEncoding.EncodeToString(payload) + signingInput := []byte(protectedB64 + "." + payloadB64) + + // Hash with SHA-256 (matches ES256 header) and sign with P-384 key + hash := sha256.Sum256(signingInput) + sigBytes, err := ecdsa.SignASN1(rand.Reader, privKey, hash[:]) + if err != nil { + t.Fatalf("Failed to sign: %v", err) + } + + sig := &agentv1alpha1.AgentCardSignature{ + Protected: protectedB64, + Signature: base64.RawURLEncoding.EncodeToString(sigBytes), + } + + result, err := VerifyJWS(cardData, sig, pubKeyPEM) + if err == nil { + t.Fatal("Expected curve mismatch error when using P-384 key with ES256 header") + } + if result.Verified { + t.Error("Expected verified=false for curve mismatch") + } + if indexOf(err.Error(), "curve mismatch") == -1 { + t.Errorf("Expected 'curve mismatch' in error, got: %v", err) + } +} + +// --- Helper --- + +func indexOf(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/kagenti-operator/scripts/generate-keys.sh b/kagenti-operator/scripts/generate-keys.sh new file mode 100755 index 00000000..a7253dcc --- /dev/null +++ b/kagenti-operator/scripts/generate-keys.sh @@ -0,0 +1,45 @@ +#!/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/sign-agent-card.py b/kagenti-operator/scripts/sign-agent-card.py new file mode 100644 index 00000000..c9ff640b --- /dev/null +++ b/kagenti-operator/scripts/sign-agent-card.py @@ -0,0 +1,214 @@ +#!/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 a PEM file.""" + 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 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) + """ + card_copy = dict(card_data) + card_copy.pop('signatures', None) + card_copy.pop('signature', None) # Legacy field, just in case + + # Remove empty/None values to match Go canonical JSON behavior + card_copy = {k: v for k, v in card_copy.items() if v is not None and v != "" and v != [] and v != {}} + + 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'): + 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' + ) + 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 d7e3d88e..e772dffa 100644 --- a/kagenti-operator/test/integration/identity_binding_integration_test.go +++ b/kagenti-operator/test/integration/identity_binding_integration_test.go @@ -169,12 +169,19 @@ func testMatchingBindingEvaluation(t *testing.T) { Client: k8sClient, Scheme: scheme, AgentFetcher: &mockFetcher{}, - TrustDomain: trustDomain, } - // Reconcile multiple times (first adds finalizer, subsequent evaluate binding) - // Use retry logic to handle "object modified" errors - for i := 0; i < 3; i++ { + // First reconcile adds finalizer + _, _ = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: cardName, Namespace: testNamespace}, + }) + time.Sleep(100 * time.Millisecond) + + // Simulate a verified signature with SPIFFE ID in JWS protected header + simulateJWSSpiffeID(t, ctx, cardName, expectedSpiffeID) + + // Subsequent reconciles evaluate binding (with JWS SPIFFE ID now in status) + for i := 0; i < 2; i++ { _, _ = reconciler.Reconcile(ctx, reconcile.Request{ NamespacedName: types.NamespacedName{Name: cardName, Namespace: testNamespace}, }) @@ -222,7 +229,7 @@ func testNonMatchingBindingEvaluation(t *testing.T) { service := createTestService(t, ctx, agentName) defer deleteResource(ctx, service) - // Create AgentCard with NON-matching SPIFFE ID + // Create AgentCard with NON-matching SPIFFE ID in allowlist wrongSpiffeID := fmt.Sprintf("spiffe://%s/ns/other/sa/other-sa", trustDomain) agentCard := createTestAgentCard(t, ctx, cardName, agentName, []agentv1alpha1.SpiffeID{agentv1alpha1.SpiffeID(wrongSpiffeID)}, false) defer deleteResource(ctx, agentCard) @@ -232,13 +239,19 @@ func testNonMatchingBindingEvaluation(t *testing.T) { Client: k8sClient, Scheme: scheme, AgentFetcher: &mockFetcher{}, - TrustDomain: trustDomain, } - // Reconcile twice + // First reconcile adds finalizer reconciler.Reconcile(ctx, reconcile.Request{ NamespacedName: types.NamespacedName{Name: cardName, Namespace: testNamespace}, }) + time.Sleep(100 * time.Millisecond) + + // Simulate JWS SPIFFE ID that does NOT match the allowlist + actualSpiffeID := fmt.Sprintf("spiffe://%s/ns/%s/sa/%s", trustDomain, testNamespace, saName) + simulateJWSSpiffeID(t, ctx, cardName, actualSpiffeID) + + // Reconcile again to evaluate binding reconciler.Reconcile(ctx, reconcile.Request{ NamespacedName: types.NamespacedName{Name: cardName, Namespace: testNamespace}, }) @@ -293,7 +306,6 @@ func testStrictBindingEnforcement(t *testing.T) { Client: k8sClient, Scheme: scheme, AgentFetcher: &mockFetcher{}, - TrustDomain: trustDomain, } // Reconcile multiple times to ensure binding is evaluated @@ -412,11 +424,19 @@ func testBindingRestoration(t *testing.T) { Client: k8sClient, Scheme: scheme, AgentFetcher: &mockFetcher{}, - TrustDomain: trustDomain, } - // Reconcile multiple times - for i := 0; i < 3; i++ { + // First reconcile adds finalizer + cardReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: cardName, Namespace: testNamespace}, + }) + time.Sleep(100 * time.Millisecond) + + // Simulate a verified signature with matching SPIFFE ID in JWS header + simulateJWSSpiffeID(t, ctx, cardName, expectedSpiffeID) + + // Subsequent reconciles evaluate binding + for i := 0; i < 2; i++ { cardReconciler.Reconcile(ctx, reconcile.Request{ NamespacedName: types.NamespacedName{Name: cardName, Namespace: testNamespace}, }) @@ -588,7 +608,6 @@ func createTestAgentCard(t *testing.T, ctx context.Context, name, agentName stri }, }, IdentityBinding: &agentv1alpha1.IdentityBinding{ - TrustDomain: trustDomain, AllowedSpiffeIDs: allowedIDs, Strict: strict, }, @@ -635,6 +654,24 @@ func createTestDeployment(t *testing.T, ctx context.Context, name string, replic return deployment } +// simulateJWSSpiffeID pre-sets the AgentCard status to simulate a verified signature +// with a SPIFFE ID in the JWS protected header. This is needed because the integration +// tests don't perform actual signing — the SPIFFE ID now comes exclusively from the +// JWS protected header (no fallback paths). +func simulateJWSSpiffeID(t *testing.T, ctx context.Context, cardName, spiffeID string) { + card := &agentv1alpha1.AgentCard{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: cardName, Namespace: testNamespace}, card); err != nil { + t.Fatalf("Failed to get AgentCard for SPIFFE ID simulation: %v", err) + } + validSig := true + card.Status.ValidSignature = &validSig + card.Status.SignatureSpiffeID = spiffeID + if err := k8sClient.Status().Update(ctx, card); err != nil { + t.Fatalf("Failed to simulate JWS SPIFFE ID: %v", err) + } + t.Logf(" Simulated JWS SPIFFE ID: %s", spiffeID) +} + func deleteResource(ctx context.Context, obj client.Object) { // Remove finalizers first obj.SetFinalizers(nil)