A Kubernetes operator that bridges Hardware Security Module (HSM) data storage with Kubernetes Secrets, providing true secret portability th
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

try and fix hsm errors related to creating new secrets

+116 -49
+3 -3
CLAUDE.md
··· 324 324 HSM_PIN=$(kubectl get secret hsm-pin -o jsonpath='{.data.pin}' | base64 -d) 325 325 326 326 # List all secrets (requires PIN authentication) 327 - kubectl exec $AGENT_POD -- pkcs11-tool --module="/usr/lib/opensc-pkcs11.so" --login --pin="$HSM_PIN" --list-objects --type=data 327 + kubectl exec $AGENT_POD -- pkcs11-tool --module="/usr/lib/pkcs11/opensc-pkcs11.so" --login --pin="$HSM_PIN" --list-objects --type=data 328 328 329 329 # Read specific secret component 330 - kubectl exec $AGENT_POD -- pkcs11-tool --module="/usr/lib/opensc-pkcs11.so" --login --pin="$HSM_PIN" --read-object --type=data --label="my-secret/api_key" 330 + kubectl exec $AGENT_POD -- pkcs11-tool --module="/usr/lib/pkcs11/opensc-pkcs11.so" --login --pin="$HSM_PIN" --read-object --type=data --label="my-secret/api_key" 331 331 332 332 # HSM device info 333 - kubectl exec $AGENT_POD -- pkcs11-tool --module="/usr/lib/opensc-pkcs11.so" -I 333 + kubectl exec $AGENT_POD -- pkcs11-tool --module="/usr/lib/pkcs11/opensc-pkcs11.so" -I 334 334 ``` 335 335 336 336 **Secret Storage Structure:**
+1 -1
Makefile
··· 3 3 # To re-generate a bundle for another specific version without changing the standard setup, you can: 4 4 # - use the VERSION as arg of the bundle target (e.g make bundle VERSION=0.0.2) 5 5 # - use environment variables to overwrite this value (e.g export VERSION=0.0.2) 6 - VERSION ?= 0.6.36 6 + VERSION ?= 0.6.37 7 7 8 8 # CHANNELS define the bundle channels used in the bundle. 9 9 # Add a new line here if you would like to change its default config. (E.g CHANNELS = "candidate,fast,stable")
+2 -2
helm/hsm-secrets-operator/Chart.yaml
··· 2 2 name: hsm-secrets-operator 3 3 description: A Kubernetes operator that bridges Pico HSM binary data storage with Kubernetes Secrets 4 4 type: application 5 - version: 0.6.36 6 - appVersion: v0.6.36 5 + version: 0.6.37 6 + appVersion: v0.6.37 7 7 icon: https://raw.githubusercontent.com/cncf/artwork/master/projects/kubernetes/icon/color/kubernetes-icon-color.svg 8 8 home: https://github.com/evanjarrett/hsm-secrets-operator 9 9 sources:
+12 -4
internal/api/auth_integration_test.go
··· 65 65 var response map[string]any 66 66 err := json.Unmarshal(w.Body.Bytes(), &response) 67 67 require.NoError(t, err) 68 - assert.Contains(t, response["error"], "invalid token") 68 + errorObj, ok := response["error"].(map[string]any) 69 + require.True(t, ok, "error should be an object") 70 + assert.Contains(t, errorObj["message"], "invalid") 69 71 }) 70 72 71 73 t.Run("Missing Authorization Header", func(t *testing.T) { ··· 81 83 var response map[string]any 82 84 err := json.Unmarshal(w.Body.Bytes(), &response) 83 85 require.NoError(t, err) 84 - assert.Contains(t, response["error"], "missing authorization header") 86 + errorObj, ok := response["error"].(map[string]any) 87 + require.True(t, ok, "error should be an object") 88 + assert.Contains(t, errorObj["message"], "missing authorization header") 85 89 }) 86 90 87 91 t.Run("Malformed Authorization Header", func(t *testing.T) { ··· 98 102 var response map[string]any 99 103 err := json.Unmarshal(w.Body.Bytes(), &response) 100 104 require.NoError(t, err) 101 - assert.Contains(t, response["error"], "invalid authorization header format") 105 + errorObj, ok := response["error"].(map[string]any) 106 + require.True(t, ok, "error should be an object") 107 + assert.Contains(t, errorObj["message"], "invalid authorization header format") 102 108 }) 103 109 104 110 t.Run("Health Endpoint Accessible Without Auth", func(t *testing.T) { ··· 141 147 require.NoError(t, err) 142 148 143 149 // Should contain details about the K8s token failure, not JWT auth failure 144 - assert.Contains(t, response["error"], "failed to generate token") 150 + errorObj, ok := response["error"].(map[string]any) 151 + require.True(t, ok, "error should be an object") 152 + assert.Contains(t, errorObj["message"], "failed to generate") 145 153 }) 146 154 147 155 t.Run("JWT Authentication Enabled", func(t *testing.T) {
+19 -13
internal/hsm/pkcs11_cgo.go
··· 226 226 if err := session.ctx.FindObjectsInit(session.session, template); err != nil { 227 227 return nil, fmt.Errorf("failed to initialize object search: %w", err) 228 228 } 229 - defer func() { 230 - if finalErr := session.ctx.FindObjectsFinal(session.session); finalErr != nil { 231 - // Ignore finalize error but continue 232 - _ = finalErr 233 - } 234 - }() 235 229 236 230 // Get all matching objects 237 - objs, _, err := session.ctx.FindObjects(session.session, 1000) // Max 1000 objects 231 + objs, _, err := session.ctx.FindObjects(session.session, 100) // Max 100 objects (reduced from 1000 to be less aggressive) 238 232 if err != nil { 233 + // Always finalize before returning error 234 + _ = session.ctx.FindObjectsFinal(session.session) 239 235 return nil, fmt.Errorf("failed to find objects: %w", err) 236 + } 237 + 238 + // CRITICAL: Must call FindObjectsFinal to release search operation 239 + // If this fails, the session remains in search mode and CreateObject will fail 240 + if err := session.ctx.FindObjectsFinal(session.session); err != nil { 241 + return nil, fmt.Errorf("failed to finalize object search (session may be in invalid state): %w", err) 240 242 } 241 243 242 244 // Pre-allocate slice for better performance ··· 334 336 if err := session.ctx.FindObjectsInit(session.session, template); err != nil { 335 337 return fmt.Errorf("failed to initialize object search: %w", err) 336 338 } 337 - defer func() { 338 - if finalErr := session.ctx.FindObjectsFinal(session.session); finalErr != nil { 339 - // Ignore finalize error but continue 340 - _ = finalErr 341 - } 342 - }() 343 339 344 340 // Get all matching objects 345 341 objs, _, err := session.ctx.FindObjects(session.session, 100) // Max 100 objects 346 342 if err != nil { 343 + // Always finalize before returning error 344 + _ = session.ctx.FindObjectsFinal(session.session) 347 345 return fmt.Errorf("failed to find objects: %w", err) 348 346 } 349 347 350 348 // Delete each object that matches our path 349 + deletedCount := 0 351 350 for _, obj := range objs { 352 351 // Get the label to check if this object matches our path 353 352 labelAttr, err := session.ctx.GetAttributeValue(session.session, obj, []*pkcs11.Attribute{ ··· 371 370 // Log error but continue with other objects 372 371 continue 373 372 } 373 + deletedCount++ 374 + } 375 + 376 + // CRITICAL: Must call FindObjectsFinal to release search operation 377 + // If this fails, the session remains in search mode and CreateObject will fail 378 + if err := session.ctx.FindObjectsFinal(session.session); err != nil { 379 + return fmt.Errorf("failed to finalize object search after deleting %d objects (session may be in invalid state): %w", deletedCount, err) 374 380 } 375 381 376 382 return nil
+3 -1
internal/hsm/pkcs11_client.go
··· 232 232 "path", path, "keys", len(data)) 233 233 234 234 // First, delete any existing objects for this path to avoid duplicates 235 + // IMPORTANT: Do not ignore errors here - if FindObjectsFinal fails in delete, 236 + // the session will be in an invalid state and CreateObject will fail 235 237 if err := deleteSecretObjectsPKCS11(c.session, path); err != nil { 236 - c.logger.V(1).Info("Failed to delete existing objects (may not exist)", "error", err) 238 + return fmt.Errorf("failed to prepare HSM for write (delete existing objects): %w", err) 237 239 } 238 240 239 241 // Create data objects for each key-value pair
+44 -5
internal/security/api_auth.go
··· 214 214 authHeader := c.GetHeader(TokenHeader) 215 215 if authHeader == "" { 216 216 a.logger.Info("Missing authorization header", "path", c.Request.URL.Path, "client_ip", c.ClientIP()) 217 - c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"}) 217 + c.JSON(http.StatusUnauthorized, gin.H{ 218 + "success": false, 219 + "error": gin.H{ 220 + "code": "MISSING_AUTH_HEADER", 221 + "message": "missing authorization header", 222 + }, 223 + }) 218 224 c.Abort() 219 225 return 220 226 } 221 227 222 228 if !strings.HasPrefix(authHeader, BearerPrefix) { 223 229 a.logger.Info("Invalid authorization header format", "path", c.Request.URL.Path, "client_ip", c.ClientIP()) 224 - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header format"}) 230 + c.JSON(http.StatusUnauthorized, gin.H{ 231 + "success": false, 232 + "error": gin.H{ 233 + "code": "INVALID_AUTH_FORMAT", 234 + "message": "invalid authorization header format, expected 'Bearer <token>'", 235 + }, 236 + }) 225 237 c.Abort() 226 238 return 227 239 } ··· 232 244 claims, err := a.ValidateToken(tokenString) 233 245 if err != nil { 234 246 a.logger.Info("Token validation failed", "error", err, "path", c.Request.URL.Path, "client_ip", c.ClientIP()) 235 - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) 247 + c.JSON(http.StatusUnauthorized, gin.H{ 248 + "success": false, 249 + "error": gin.H{ 250 + "code": "INVALID_TOKEN", 251 + "message": "authentication token is invalid or expired", 252 + "details": gin.H{ 253 + "hint": "token may have expired, try clearing cache with: kubectl hsm auth clear", 254 + }, 255 + }, 256 + }) 236 257 c.Abort() 237 258 return 238 259 } ··· 281 302 return func(c *gin.Context) { 282 303 var req TokenRequest 283 304 if err := c.ShouldBindJSON(&req); err != nil { 284 - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request", "details": err.Error()}) 305 + c.JSON(http.StatusBadRequest, gin.H{ 306 + "success": false, 307 + "error": gin.H{ 308 + "code": "INVALID_REQUEST", 309 + "message": "invalid request body", 310 + "details": gin.H{ 311 + "error": err.Error(), 312 + }, 313 + }, 314 + }) 285 315 return 286 316 } 287 317 ··· 289 319 token, err := a.GenerateToken(c.Request.Context(), req.K8sToken) 290 320 if err != nil { 291 321 a.logger.Info("Token generation failed", "error", err, "client_ip", c.ClientIP()) 292 - c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to generate token", "details": err.Error()}) 322 + c.JSON(http.StatusUnauthorized, gin.H{ 323 + "success": false, 324 + "error": gin.H{ 325 + "code": "TOKEN_GENERATION_FAILED", 326 + "message": "failed to generate authentication token", 327 + "details": gin.H{ 328 + "error": err.Error(), 329 + }, 330 + }, 331 + }) 293 332 return 294 333 } 295 334
+6 -6
kubectl-hsm/pkg/auth/jwt_auth.go
··· 43 43 44 44 // TokenManager handles JWT token caching and automatic refresh 45 45 type TokenManager struct { 46 - baseURL string 47 - k8sClient kubernetes.Interface 46 + baseURL string 47 + k8sClient kubernetes.Interface 48 48 serviceAccount string 49 - namespace string 50 - httpClient *http.Client 51 - cachedToken *CachedToken 49 + namespace string 50 + httpClient *http.Client 51 + cachedToken *CachedToken 52 52 } 53 53 54 54 // CachedToken represents a cached JWT token ··· 309 309 310 310 cacheFile := filepath.Join(homeDir, TokenCacheDir, TokenCacheFile) 311 311 return os.Remove(cacheFile) 312 - } 312 + }
+14 -1
kubectl-hsm/pkg/client/client.go
··· 270 270 return fmt.Errorf("failed to read response body: %w", err) 271 271 } 272 272 273 + // Handle 401 Unauthorized - clear cached token 274 + if resp.StatusCode == http.StatusUnauthorized { 275 + if c.tokenManager != nil { 276 + // Clear the cached token so next request gets a fresh one 277 + _ = c.tokenManager.ClearCache() 278 + } 279 + } 280 + 273 281 var apiResp APIResponse 274 282 if err := json.Unmarshal(respBody, &apiResp); err != nil { 275 283 return fmt.Errorf("failed to parse API response: %w", err) ··· 278 286 // Check if the API reported an error 279 287 if !apiResp.Success { 280 288 if apiResp.Error != nil { 281 - return fmt.Errorf("API error (%s): %s", apiResp.Error.Code, apiResp.Error.Message) 289 + errMsg := fmt.Sprintf("API error (%s): %s", apiResp.Error.Code, apiResp.Error.Message) 290 + // Add helpful hint for authentication errors 291 + if resp.StatusCode == http.StatusUnauthorized { 292 + errMsg += "\n\nAuthentication token was invalid or expired. The cached token has been cleared.\nPlease retry your command to authenticate with a fresh token." 293 + } 294 + return fmt.Errorf("%s", errMsg) 282 295 } 283 296 return fmt.Errorf("API request failed: %s", apiResp.Message) 284 297 }
+1 -1
kubectl-hsm/pkg/commands/auth.go
··· 164 164 cmd.Flags().StringVar(&namespace, "namespace", "", "Namespace for the service account") 165 165 166 166 return cmd 167 - } 167 + }
+9 -9
kubectl-hsm/pkg/commands/common.go
··· 181 181 } 182 182 183 183 ext := strings.ToLower(filepath.Ext(filename)) 184 - 184 + 185 185 // Auto-detect format based on file extension 186 186 switch ext { 187 187 case ".env": ··· 196 196 if data, err := tryParseAsEnv(filename, content); err == nil { 197 197 return data, nil 198 198 } 199 - 199 + 200 200 // Fall back to single key-value format 201 201 return readAsSingleKeyValue(key, filename, content) 202 202 } ··· 208 208 if err := json.Unmarshal(content, &data); err != nil { 209 209 return nil, err 210 210 } 211 - 211 + 212 212 // Check if this looks like secret data (not nested structures) 213 213 for key, value := range data { 214 214 if key == "" { 215 215 return nil, fmt.Errorf("empty key found") 216 216 } 217 - 217 + 218 218 // Convert all values to strings, reject complex nested structures 219 219 switch v := value.(type) { 220 220 case string: ··· 229 229 data[key] = fmt.Sprintf("%v", v) 230 230 } 231 231 } 232 - 232 + 233 233 return data, nil 234 234 } 235 235 ··· 237 237 func tryParseAsEnv(filename string, content []byte) (map[string]any, error) { 238 238 lines := strings.Split(string(content), "\n") 239 239 hasKeyValuePairs := false 240 - 240 + 241 241 for _, line := range lines { 242 242 line = strings.TrimSpace(line) 243 243 if line == "" || strings.HasPrefix(line, "#") { ··· 248 248 break 249 249 } 250 250 } 251 - 251 + 252 252 if !hasKeyValuePairs { 253 253 return nil, fmt.Errorf("no KEY=VALUE pairs found") 254 254 } 255 - 255 + 256 256 return readFromEnvContent(filename, content) 257 257 } 258 258 ··· 323 323 if key == "" { 324 324 return nil, fmt.Errorf("empty key found in JSON file %s", filename) 325 325 } 326 - 326 + 327 327 // Convert non-string values to strings 328 328 switch v := value.(type) { 329 329 case string:
-1
kubectl-hsm/pkg/commands/create.go
··· 94 94 return cmd 95 95 } 96 96 97 - 98 97 // RunWithCommandName executes the command with a specific command name for messaging 99 98 func (opts *CreateOptions) RunWithCommandName(ctx context.Context, secretName, commandName string) error { 100 99 // Validate secret name
+1 -1
kubectl-hsm/pkg/commands/mirror.go
··· 175 175 default: 176 176 return fmt.Errorf("unsupported output format: %s", outputFormat) 177 177 } 178 - } 178 + }
+1 -1
kubectl-hsm/pkg/commands/rename_key.go
··· 145 145 } 146 146 147 147 return nil 148 - } 148 + }