Skip to content

Commit dacc3fa

Browse files
committed
feat(utils): support k8s:// URLs for VSA signing keys
Add support for reading VSA signing keys from Kubernetes secrets using k8s://namespace/secret-name/key-field format, in addition to file paths. Consolidates key resolution logic for both public and private keys. - Add KeyFromKeyRef utility for unified key resolution - Support both file paths and k8s:// URLs for private keys - Maintain backward compatibility with existing file-based keys
1 parent d4988d1 commit dacc3fa

File tree

8 files changed

+626
-14
lines changed

8 files changed

+626
-14
lines changed

cmd/validate/image.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,8 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
497497
}
498498

499499
if data.vsaEnabled {
500-
signer, err := vsa.NewSigner(data.vsaSigningKey, utils.FS(cmd.Context()))
500+
// Use the signer function that supports both file and k8s:// URLs
501+
signer, err := vsa.NewSigner(cmd.Context(), data.vsaSigningKey, utils.FS(cmd.Context()))
501502
if err != nil {
502503
log.Error(err)
503504
return err
@@ -669,7 +670,7 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
669670
- "ec-policy": Uses Enterprise Contract policy filtering with pipeline intention support`))
670671

671672
cmd.Flags().BoolVar(&data.vsaEnabled, "vsa", false, "Generate a Verification Summary Attestation (VSA) for each validated image.")
672-
cmd.Flags().StringVar(&data.vsaSigningKey, "vsa-signing-key", "", "Path to the private key for signing the VSA.")
673+
cmd.Flags().StringVar(&data.vsaSigningKey, "vsa-signing-key", "", "Path to the private key for signing the VSA. Supports file paths and Kubernetes secret references (k8s://namespace/secret-name/key-field).")
673674
cmd.Flags().StringSliceVar(&data.vsaUpload, "vsa-upload", nil, "Storage backends for VSA upload. Format: backend@url?param=value. Examples: rekor@https://rekor.sigstore.dev, local@./vsa-dir")
674675
cmd.Flags().DurationVar(&data.vsaExpiration, "vsa-expiration", data.vsaExpiration, "Expiration threshold for existing VSAs. If a valid VSA exists and is newer than this threshold, validation will be skipped. (default 168h)")
675676

docs/modules/ROOT/pages/ec_validate_image.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ JSON of the "spec" or a reference to a Kubernetes object [<namespace>/]<name>
154154
-s, --strict:: Return non-zero status on non-successful validation. Defaults to true. Use --strict=false to return a zero status code. (Default: true)
155155
--vsa:: Generate a Verification Summary Attestation (VSA) for each validated image. (Default: false)
156156
--vsa-expiration:: Expiration threshold for existing VSAs. If a valid VSA exists and is newer than this threshold, validation will be skipped. (default 168h) (Default: 168h0m0s)
157-
--vsa-signing-key:: Path to the private key for signing the VSA.
157+
--vsa-signing-key:: Path to the private key for signing the VSA. Supports file paths and Kubernetes secret references (k8s://namespace/secret-name/key-field).
158158
--vsa-upload:: Storage backends for VSA upload. Format: backend@url?param=value. Examples: rekor@https://rekor.sigstore.dev, local@./vsa-dir (Default: [])
159159
--workers:: Number of workers to use for validation. Defaults to 5. (Default: 5)
160160

internal/utils/key_resolver.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright The Conforma Contributors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
17+
package utils
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"strings"
23+
24+
"github.com/spf13/afero"
25+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
26+
"k8s.io/client-go/kubernetes"
27+
"k8s.io/client-go/rest"
28+
"k8s.io/client-go/tools/clientcmd"
29+
)
30+
31+
// contextKey is a custom type for context keys to avoid collisions
32+
type contextKey string
33+
34+
const K8sClientKey contextKey = "k8s.client"
35+
36+
// KeyFromKeyRef resolves a key from either a file path or a Kubernetes secret reference.
37+
// This provides a unified interface for both public and private key resolution.
38+
// Supported formats:
39+
// - File path: "/path/to/key.pem"
40+
// - Kubernetes secret: "k8s://namespace/secret-name/key-field" (explicit key field)
41+
// - Kubernetes secret: "k8s://namespace/secret-name" (auto-select if single key exists)
42+
func KeyFromKeyRef(ctx context.Context, keyRef string, fs afero.Fs) ([]byte, error) {
43+
if strings.HasPrefix(keyRef, "k8s://") {
44+
return keyFromKubernetesSecret(ctx, keyRef)
45+
}
46+
return keyFromFile(keyRef, fs)
47+
}
48+
49+
// PublicKeyFromKeyRef resolves a public key from either a file path or a Kubernetes secret reference.
50+
// This provides a consistent interface with PrivateKeyFromKeyRef.
51+
// Supported formats:
52+
// - File path: "/path/to/public-key.pem"
53+
// - Kubernetes secret: "k8s://namespace/secret-name/key-field" (explicit key field)
54+
// - Kubernetes secret: "k8s://namespace/secret-name" (auto-select if single key exists)
55+
func PublicKeyFromKeyRef(ctx context.Context, keyRef string, fs afero.Fs) ([]byte, error) {
56+
return KeyFromKeyRef(ctx, keyRef, fs)
57+
}
58+
59+
// keyFromFile reads a key from the filesystem
60+
func keyFromFile(keyPath string, fs afero.Fs) ([]byte, error) {
61+
keyBytes, err := afero.ReadFile(fs, keyPath)
62+
if err != nil {
63+
return nil, fmt.Errorf("read key from file %q: %w", keyPath, err)
64+
}
65+
return keyBytes, nil
66+
}
67+
68+
// keyFromKubernetesSecret reads a key from a Kubernetes secret
69+
// Supported formats:
70+
// - k8s://namespace/secret-name/key-field (explicit key field)
71+
// - k8s://namespace/secret-name (auto-select if single key exists)
72+
func keyFromKubernetesSecret(ctx context.Context, keyRef string) ([]byte, error) {
73+
// Validate format first before attempting to create client
74+
parts := strings.Split(strings.TrimPrefix(keyRef, "k8s://"), "/")
75+
if len(parts) < 2 || len(parts) > 3 {
76+
return nil, fmt.Errorf("invalid k8s key reference format %q, expected k8s://namespace/secret-name or k8s://namespace/secret-name/key-field", keyRef)
77+
}
78+
79+
namespace := parts[0]
80+
secretName := parts[1]
81+
var keyField string
82+
if len(parts) == 3 {
83+
keyField = parts[2]
84+
}
85+
86+
if namespace == "" || secretName == "" {
87+
return nil, fmt.Errorf("invalid k8s key reference %q: namespace and secret name must be specified", keyRef)
88+
}
89+
90+
k8sClient, err := getKubernetesClient(ctx)
91+
if err != nil {
92+
return nil, fmt.Errorf("get kubernetes client: %w", err)
93+
}
94+
95+
secret, err := k8sClient.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{})
96+
if err != nil {
97+
return nil, fmt.Errorf("fetch secret %s/%s: %w", namespace, secretName, err)
98+
}
99+
100+
// If key field is specified, use it directly
101+
if keyField != "" {
102+
keyData, exists := secret.Data[keyField]
103+
if !exists {
104+
return nil, fmt.Errorf("key field %q not found in secret %s/%s", keyField, namespace, secretName)
105+
}
106+
return keyData, nil
107+
}
108+
109+
// No key field specified - auto-select if single key exists
110+
keyCount := len(secret.Data)
111+
if keyCount == 0 {
112+
return nil, fmt.Errorf("secret %s/%s contains no keys", namespace, secretName)
113+
}
114+
if keyCount == 1 {
115+
// Get the single key
116+
for _, keyData := range secret.Data {
117+
return keyData, nil
118+
}
119+
}
120+
121+
// Multiple keys exist - return error without exposing key names
122+
return nil, fmt.Errorf("secret %s/%s contains multiple keys, please specify the key field: k8s://%s/%s/<key-field>",
123+
namespace, secretName, namespace, secretName)
124+
}
125+
126+
// getKubernetesClient retrieves a Kubernetes client from the context or creates a new one
127+
func getKubernetesClient(ctx context.Context) (kubernetes.Interface, error) {
128+
// Try to get from context first (for testing)
129+
if client, ok := ctx.Value(K8sClientKey).(kubernetes.Interface); ok {
130+
return client, nil
131+
}
132+
133+
// Create a new client using the same pattern as the existing kubernetes package
134+
// This follows the same pattern as used in the policy package
135+
config, err := getKubernetesConfig()
136+
if err != nil {
137+
return nil, fmt.Errorf("get kubernetes config: %w", err)
138+
}
139+
140+
client, err := kubernetes.NewForConfig(config)
141+
if err != nil {
142+
return nil, fmt.Errorf("create kubernetes client: %w", err)
143+
}
144+
145+
return client, nil
146+
}
147+
148+
// getKubernetesConfig creates a Kubernetes config following the same pattern as the existing code
149+
func getKubernetesConfig() (*rest.Config, error) {
150+
rules := clientcmd.NewDefaultClientConfigLoadingRules()
151+
clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, nil)
152+
return clientConfig.ClientConfig()
153+
}

internal/utils/private_key.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright The Conforma Contributors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
17+
package utils
18+
19+
import (
20+
"context"
21+
22+
"github.com/spf13/afero"
23+
)
24+
25+
// PrivateKeyFromKeyRef resolves a private key from either a file path or a Kubernetes secret reference.
26+
// This follows the same pattern as cosignSig.PublicKeyFromKeyRef but for private keys.
27+
// Supported formats:
28+
// - File path: "/path/to/private-key.pem"
29+
// - Kubernetes secret: "k8s://namespace/secret-name/key-field"
30+
func PrivateKeyFromKeyRef(ctx context.Context, keyRef string, fs afero.Fs) ([]byte, error) {
31+
return KeyFromKeyRef(ctx, keyRef, fs)
32+
}

0 commit comments

Comments
 (0)