A Kubernetes operator that bridges Hardware Security Module (HSM) data storage with Kubernetes Secrets, providing true secret portability th
1/*
2Copyright 2025.
3
4Licensed under the Apache License, Version 2.0 (the "License");
5you may not use this file except in compliance with the License.
6You may obtain a copy of the License at
7
8 http://www.apache.org/licenses/LICENSE-2.0
9
10Unless required by applicable law or agreed to in writing, software
11distributed under the License is distributed on an "AS IS" BASIS,
12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13See the License for the specific language governing permissions and
14limitations under the License.
15*/
16
17package security
18
19import (
20 "context"
21 "fmt"
22 "regexp"
23 "strings"
24 "sync"
25
26 "golang.org/x/time/rate"
27 "google.golang.org/grpc"
28 "google.golang.org/grpc/codes"
29 "google.golang.org/grpc/metadata"
30 "google.golang.org/grpc/peer"
31 "google.golang.org/grpc/status"
32)
33
34const (
35 // Maximum path length for HSM secret paths
36 MaxSecretPathLength = 256
37 // Maximum secret data size (1MB)
38 MaxSecretDataSize = 1024 * 1024
39 // Maximum metadata field length
40 MaxMetadataFieldLength = 1024
41 // Rate limit: 100 requests per minute per client
42 DefaultRateLimit = rate.Limit(100.0 / 60.0) // per second
43 // Burst allowance
44 DefaultBurst = 20
45)
46
47var (
48 // Valid secret path pattern: alphanumeric, hyphens, underscores, forward slashes
49 validPathPattern = regexp.MustCompile(`^[a-zA-Z0-9/_-]+$`)
50 // Forbidden path patterns (prevent directory traversal, etc.)
51 forbiddenPatterns = []*regexp.Regexp{
52 regexp.MustCompile(`\.\.`), // Directory traversal
53 regexp.MustCompile(`//`), // Double slashes
54 regexp.MustCompile(`^/`), // Leading slash
55 regexp.MustCompile(`/$`), // Trailing slash
56 regexp.MustCompile(`_metadata$`), // Reserved metadata suffix
57 }
58)
59
60// InputValidator validates and sanitizes input for HSM operations
61type InputValidator struct{}
62
63// NewInputValidator creates a new input validator
64func NewInputValidator() *InputValidator {
65 return &InputValidator{}
66}
67
68// ValidateSecretPath validates and sanitizes secret paths
69func (v *InputValidator) ValidateSecretPath(path string) error {
70 if path == "" {
71 return fmt.Errorf("secret path cannot be empty")
72 }
73
74 if len(path) > MaxSecretPathLength {
75 return fmt.Errorf("secret path too long: %d > %d", len(path), MaxSecretPathLength)
76 }
77
78 // Check valid pattern
79 if !validPathPattern.MatchString(path) {
80 return fmt.Errorf("secret path contains invalid characters: %s", path)
81 }
82
83 // Check forbidden patterns
84 for _, pattern := range forbiddenPatterns {
85 if pattern.MatchString(path) {
86 return fmt.Errorf("secret path contains forbidden pattern: %s", path)
87 }
88 }
89
90 return nil
91}
92
93// ValidateSecretData validates secret data size and content
94func (v *InputValidator) ValidateSecretData(data map[string][]byte) error {
95 if data == nil {
96 return fmt.Errorf("secret data cannot be nil")
97 }
98
99 if len(data) == 0 {
100 return fmt.Errorf("secret data cannot be empty")
101 }
102
103 totalSize := 0
104 for key, value := range data {
105 if key == "" {
106 return fmt.Errorf("secret data key cannot be empty")
107 }
108
109 if len(key) > MaxMetadataFieldLength {
110 return fmt.Errorf("secret data key too long: %d > %d", len(key), MaxMetadataFieldLength)
111 }
112
113 // Check for metadata key suffix (reserved)
114 if strings.HasSuffix(key, "_metadata") {
115 return fmt.Errorf("secret data key cannot end with '_metadata': %s", key)
116 }
117
118 // Validate key pattern
119 if !validPathPattern.MatchString(key) {
120 return fmt.Errorf("secret data key contains invalid characters: %s", key)
121 }
122
123 totalSize += len(value)
124 if totalSize > MaxSecretDataSize {
125 return fmt.Errorf("secret data too large: %d > %d", totalSize, MaxSecretDataSize)
126 }
127 }
128
129 return nil
130}
131
132// ValidateMetadata validates secret metadata
133func (v *InputValidator) ValidateMetadata(secretMetadata map[string]string) error {
134 if secretMetadata == nil {
135 return nil // Metadata is optional
136 }
137
138 for key, value := range secretMetadata {
139 if len(key) > MaxMetadataFieldLength {
140 return fmt.Errorf("metadata key too long: %d > %d", len(key), MaxMetadataFieldLength)
141 }
142
143 if len(value) > MaxMetadataFieldLength {
144 return fmt.Errorf("metadata value too long: %d > %d", len(value), MaxMetadataFieldLength)
145 }
146
147 // Sanitize metadata fields
148 if strings.ContainsAny(key, "\x00\n\r") {
149 return fmt.Errorf("metadata key contains invalid characters: %s", key)
150 }
151
152 if strings.ContainsAny(value, "\x00") {
153 return fmt.Errorf("metadata value contains null bytes: %s", value)
154 }
155 }
156
157 return nil
158}
159
160// RateLimiter implements per-client rate limiting for gRPC requests
161type RateLimiter struct {
162 limiters map[string]*rate.Limiter
163 mu sync.RWMutex
164 limit rate.Limit
165 burst int
166}
167
168// NewRateLimiter creates a new rate limiter
169func NewRateLimiter() *RateLimiter {
170 return &RateLimiter{
171 limiters: make(map[string]*rate.Limiter),
172 limit: DefaultRateLimit,
173 burst: DefaultBurst,
174 }
175}
176
177// NewRateLimiterWithConfig creates a rate limiter with custom settings
178func NewRateLimiterWithConfig(limit rate.Limit, burst int) *RateLimiter {
179 return &RateLimiter{
180 limiters: make(map[string]*rate.Limiter),
181 limit: limit,
182 burst: burst,
183 }
184}
185
186// getLimiter gets or creates a rate limiter for a client
187func (rl *RateLimiter) getLimiter(clientID string) *rate.Limiter {
188 rl.mu.RLock()
189 limiter, exists := rl.limiters[clientID]
190 rl.mu.RUnlock()
191
192 if exists {
193 return limiter
194 }
195
196 rl.mu.Lock()
197 defer rl.mu.Unlock()
198
199 // Double-check in case another goroutine created it
200 if limiter, exists := rl.limiters[clientID]; exists {
201 return limiter
202 }
203
204 // Create new limiter
205 limiter = rate.NewLimiter(rl.limit, rl.burst)
206 rl.limiters[clientID] = limiter
207 return limiter
208}
209
210// Allow checks if a request should be allowed for the given client
211func (rl *RateLimiter) Allow(clientID string) bool {
212 return rl.getLimiter(clientID).Allow()
213}
214
215// getClientID extracts a client identifier from the gRPC context
216func getClientID(ctx context.Context) string {
217 // Try to get peer information
218 if p, ok := peer.FromContext(ctx); ok {
219 return p.Addr.String()
220 }
221
222 // Try to get metadata
223 if md, ok := metadata.FromIncomingContext(ctx); ok {
224 if clientIDs := md.Get("client-id"); len(clientIDs) > 0 {
225 return clientIDs[0]
226 }
227 }
228
229 return "unknown"
230}
231
232// RateLimitInterceptor returns a gRPC unary interceptor for rate limiting
233func (rl *RateLimiter) RateLimitInterceptor() grpc.UnaryServerInterceptor {
234 return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
235 clientID := getClientID(ctx)
236
237 if !rl.Allow(clientID) {
238 return nil, status.Errorf(codes.ResourceExhausted, "rate limit exceeded for client %s", clientID)
239 }
240
241 return handler(ctx, req)
242 }
243}
244
245// ValidationInterceptor returns a gRPC unary interceptor for input validation
246func ValidationInterceptor(validator *InputValidator) grpc.UnaryServerInterceptor {
247 return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
248 // Type-specific validation based on request type
249 switch r := req.(type) {
250 case interface{ GetPath() string }:
251 if err := validator.ValidateSecretPath(r.GetPath()); err != nil {
252 return nil, status.Errorf(codes.InvalidArgument, "invalid path: %v", err)
253 }
254 }
255
256 // Additional validation for write requests
257 switch r := req.(type) {
258 case interface {
259 GetSecretData() interface{ GetData() map[string][]byte }
260 }:
261 if secretData := r.GetSecretData(); secretData != nil {
262 if err := validator.ValidateSecretData(secretData.GetData()); err != nil {
263 return nil, status.Errorf(codes.InvalidArgument, "invalid secret data: %v", err)
264 }
265 }
266 case interface {
267 GetMetadata() interface{ GetLabels() map[string]string }
268 }:
269 if metadata := r.GetMetadata(); metadata != nil {
270 if err := validator.ValidateMetadata(metadata.GetLabels()); err != nil {
271 return nil, status.Errorf(codes.InvalidArgument, "invalid metadata: %v", err)
272 }
273 }
274 }
275
276 return handler(ctx, req)
277 }
278}
279
280// SecurityInterceptor combines multiple security checks
281func SecurityInterceptor(rateLimiter *RateLimiter, validator *InputValidator) grpc.UnaryServerInterceptor {
282 return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
283 // Rate limiting
284 clientID := getClientID(ctx)
285 if !rateLimiter.Allow(clientID) {
286 return nil, status.Errorf(codes.ResourceExhausted, "rate limit exceeded for client %s", clientID)
287 }
288
289 // Input validation
290 switch r := req.(type) {
291 case interface{ GetPath() string }:
292 if err := validator.ValidateSecretPath(r.GetPath()); err != nil {
293 return nil, status.Errorf(codes.InvalidArgument, "invalid path: %v", err)
294 }
295 }
296
297 switch r := req.(type) {
298 case interface {
299 GetSecretData() interface{ GetData() map[string][]byte }
300 }:
301 if secretData := r.GetSecretData(); secretData != nil {
302 if err := validator.ValidateSecretData(secretData.GetData()); err != nil {
303 return nil, status.Errorf(codes.InvalidArgument, "invalid secret data: %v", err)
304 }
305 }
306 case interface {
307 GetMetadata() interface{ GetLabels() map[string]string }
308 }:
309 if metadata := r.GetMetadata(); metadata != nil {
310 if err := validator.ValidateMetadata(metadata.GetLabels()); err != nil {
311 return nil, status.Errorf(codes.InvalidArgument, "invalid metadata: %v", err)
312 }
313 }
314 }
315
316 return handler(ctx, req)
317 }
318}