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.

update pkcs11 formatting

+737 -103
+131 -74
CLAUDE.md
··· 10 10 11 11 ### Building and Testing 12 12 ```bash 13 - # Build both binaries 14 - make build # Builds bin/manager and bin/discovery 13 + # Build all binaries (manager, discovery, agent) 14 + make build # Builds bin/manager, bin/discovery, bin/agent 15 15 16 - # Build specific components 16 + # Build specific components individually 17 17 go build -o bin/manager cmd/manager/main.go 18 - go build -o bin/discovery cmd/discovery/main.go 18 + go build -o bin/discovery cmd/discovery/main.go 19 19 go build -o bin/agent cmd/agent/main.go 20 20 go build -o bin/test-hsm cmd/test-hsm/main.go 21 21 22 - # Run tests 22 + # Run local development 23 + make run # Run manager locally (requires cluster access) 24 + 25 + # Testing 23 26 make test # Run unit tests with coverage 24 - make test-e2e # Run end-to-end tests (requires Kind cluster) 27 + make test-e2e # Run end-to-end tests (requires Kind cluster) 25 28 make setup-test-e2e # Set up Kind cluster for e2e testing 26 29 make cleanup-test-e2e # Tear down Kind cluster 27 30 ··· 29 32 # To trigger E2E tests manually in GitHub Actions: 30 33 # Go to Actions tab -> "E2E Tests" -> "Run workflow" 31 34 32 - # Run specific test package 35 + # Run specific test packages 33 36 go test ./internal/controller -v 34 37 go test ./internal/hsm -v 35 38 go test ./internal/discovery -v 36 39 go test ./internal/api -v 37 40 38 41 # Code quality (ALWAYS RUN BEFORE COMMITTING) 39 - make fmt # Format code (or: gofmt -w .) 42 + make fmt # Format code (runs go fmt ./...) 40 43 make vet # Run go vet 41 - make lint # Run golangci-lint ./... (fixed to scan all packages) 44 + make lint # Run golangci-lint ./... (configured via .golangci.yml) 42 45 make lint-fix # Run golangci-lint with auto-fixes 46 + make lint-config # Verify golangci-lint configuration 43 47 make quality # Run all quality checks (fmt + vet + lint) 44 48 45 - # Quality check workflow for development 49 + # Quality check workflow for development 46 50 make quality # ONE COMMAND: Format + vet + lint (RECOMMENDED) 47 51 # OR run individually: 48 52 gofmt -w . # Format all Go files 49 53 golangci-lint run ./... # Lint all packages (REQUIRED before code changes) 50 54 51 - # Sync CRDs from config/ to helm/ after CRD changes 52 - make helm-sync # Sync generated CRDs to Helm crds/ directory 55 + # CRD and manifest generation 56 + make manifests # Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects 57 + make generate # Generate DeepCopy methods for CRD types 58 + make helm-sync # Sync generated CRDs from config/ to helm/ after CRD changes 53 59 ``` 54 60 55 61 ### Docker Images ··· 60 66 # Testing image (all binaries without CGO, uses mock clients only) 61 67 make docker-build-testing IMG=hsm-secrets-operator:latest 62 68 63 - # Discovery image (native sysfs, distroless, no external dependencies) 64 - make docker-build-discovery DISCOVERY_IMG=hsm-discovery:latest 69 + # Push images to registry 70 + make docker-push IMG=<registry>/hsm-secrets-operator:tag 71 + 72 + # Multi-architecture build and push 73 + make docker-build-multiarch IMG=<registry>/hsm-secrets-operator:tag 65 74 66 - # Build both manager and discovery images 67 - make docker-build-all 75 + # Build installer bundle (consolidated YAML) 76 + make build-installer IMG=<registry>/hsm-secrets-operator:tag 68 77 ``` 69 78 70 79 ### PKCS#11 Build Architecture ··· 108 117 # Template Helm chart for validation 109 118 helm template test helm/hsm-secrets-operator 110 119 120 + # Install from local chart 121 + helm install hsm-secrets-operator helm/hsm-secrets-operator \ 122 + --namespace hsm-secrets-operator-system \ 123 + --create-namespace 124 + 125 + # Upgrade existing installation 126 + helm upgrade hsm-secrets-operator helm/hsm-secrets-operator \ 127 + --namespace hsm-secrets-operator-system 128 + 129 + # Uninstall 130 + helm uninstall hsm-secrets-operator -n hsm-secrets-operator-system 131 + 111 132 # Sync CRDs to Helm after changes 112 133 make helm-sync # Copies CRDs from config/crd/bases/ to helm/hsm-secrets-operator/crds/ 113 134 ``` ··· 136 157 - **The status update loop bug** was caught by adding proper linting workflows 137 158 138 159 **Before committing any changes:** 139 - 1. ✅ `gofmt -w .` (format all files) 140 - 2. ✅ `golangci-lint run ./...` (must show "0 issues") 141 - 3. ✅ `make test` (all tests must pass) 142 - 4. ✅ Test your changes locally 160 + 1. ✅ `make quality` (format + vet + lint) 161 + 2. ✅ `make test` (all tests must pass) 162 + 3. ✅ Test your changes locally with actual deployment 163 + 164 + ## Common Development Workflows 165 + 166 + ### Adding a New CRD Field 167 + ```bash 168 + # 1. Modify the CRD struct in api/v1alpha1/ 169 + # 2. Regenerate manifests and code 170 + make manifests generate 171 + 172 + # 3. Sync CRDs to Helm chart 173 + make helm-sync 174 + 175 + # 4. Run quality checks 176 + make quality 177 + 178 + # 5. Test the changes 179 + make test 180 + ``` 181 + 182 + ### Working with Controllers 183 + ```bash 184 + # Run unit tests for specific controller 185 + go test ./internal/controller -run TestHSMSecretController -v 186 + 187 + # Test controller with actual cluster (local development) 188 + make run 189 + 190 + # Build and test with Docker 191 + make docker-build IMG=test:latest 192 + ``` 193 + 194 + ### API Development and Testing 195 + ```bash 196 + # Test API endpoints locally 197 + cd examples/api && ./health-check.sh 198 + 199 + # Create test secrets via API 200 + ./create-secret.sh my-test-secret 201 + 202 + # List all secrets 203 + ./list-secrets.sh 204 + ``` 143 205 144 206 ## Project Overview 145 207 ··· 588 650 kubectl logs -n hsm-secrets-operator-system -l app=hsm-device-discovery 589 651 ``` 590 652 591 - ### Complete Files Structure 653 + ### Project Structure Overview 654 + 655 + **Key Architecture Components:** 592 656 ``` 593 - ├── api/v1alpha1/ 594 - │ ├── hsmsecret_types.go # HSMSecret CRD with mirroring support 595 - │ ├── hsmdevice_types.go # HSMDevice CRD with USB discovery 596 - │ └── groupversion_info.go # API group metadata 597 - ├── cmd/ 598 - │ ├── manager/main.go # Manager binary (HSMSecret controller + API) 599 - │ └── discovery/main.go # Discovery binary (HSMDevice controller) 657 + ├── cmd/ # Entry points for all binaries 658 + │ ├── manager/main.go # Manager: HSMSecret controller + API proxy 659 + │ ├── discovery/main.go # Discovery: HSMPool controller (removed from new arch) 660 + │ ├── agent/main.go # Agent: Direct HSM communication 661 + │ └── test-hsm/main.go # Test utility for HSM operations 662 + ├── api/v1alpha1/ # CRD definitions 663 + │ ├── hsmsecret_types.go # HSMSecret CRD 664 + │ ├── hsmpool_types.go # HSMPool CRD (race-free aggregation) 665 + │ └── hsmdevice_types.go # HSMDevice CRD (readonly specs) 600 666 ├── internal/ 601 - │ ├── controller/ 602 - │ │ ├── hsmsecret_controller.go # Secret reconciliation with fallback 603 - │ │ └── hsmdevice_controller.go # Device discovery (FIXED: no more loops!) 604 - │ ├── discovery/ 605 - │ │ ├── usb.go # USB device discovery (Talos host path support) 606 - │ │ ├── mirroring.go # Cross-node device mirroring 607 - │ │ └── deviceplugin.go # Kubernetes device management 608 - │ ├── hsm/ 609 - │ │ ├── client.go # HSM client interface 610 - │ │ ├── mock_client.go # Full test implementation 611 - │ │ └── pkcs11_client.go # Production PKCS#11 client 612 - │ └── api/ 613 - │ ├── server.go # REST API server with Gin 614 - │ ├── handlers.go # HTTP request handlers 615 - │ └── middleware.go # API middleware 616 - ├── examples/ 617 - │ ├── basic/ # Basic usage examples 618 - │ ├── advanced/ # Advanced configurations 619 - │ │ ├── talos-deployment.yaml # Talos Linux deployment 620 - │ │ ├── talos-build-guide.md # Talos setup guide 621 - │ │ └── custom-library-guide.md # PKCS#11 library integration 622 - │ └── api/ # API usage examples 623 - │ ├── bulk-operations.sh # Basic bulk operations 624 - │ ├── advanced-bulk-import.sh # Advanced bulk import 625 - │ ├── direct-import-examples.sh # Direct API examples 626 - │ ├── production-import.json # Sample production config 627 - │ └── bulk-secrets.json # Sample bulk config 628 - ├── scripts/ 629 - │ └── build-talos.sh # Talos Linux build automation 630 - ├── deploy/ 631 - │ └── talos/ # Talos-specific manifests 632 - │ ├── daemonset-discovery.yaml # Fixed discovery DaemonSet (no loops!) 633 - │ └── README.md # Talos deployment guide 634 - ├── config/ 635 - │ ├── crd/bases/ # Generated CRD manifests 636 - │ ├── rbac/ # Generated RBAC rules 637 - │ └── samples/ # Sample resources 638 - ├── Dockerfile # Manager image (with HSM libraries) 639 - ├── Dockerfile.discovery # Discovery image (lightweight distroless) 640 - ├── Dockerfile.talos # Talos-optimized image 641 - ├── test-loop-fix.yaml # Test pod for verifying loop fix 642 - ├── STATUS-UPDATE-LOOP-FIX.md # Documentation of critical fix 643 - └── CLAUDE.md # This file (updated with fixes!) 667 + │ ├── controller/ # Kubernetes controllers 668 + │ │ ├── hsmsecret_controller.go # Secret sync 669 + │ │ ├── hsmpool_controller.go # Device aggregation (NEW) 670 + │ │ ├── hsmpool_agent_controller.go # Agent deployment (NEW) 671 + │ │ └── discovery_daemonset_controller.go # DaemonSet management 672 + │ ├── hsm/ # HSM client abstraction 673 + │ │ ├── client.go # Interface definition 674 + │ │ ├── mock_client.go # Testing implementation 675 + │ │ ├── pkcs11_client.go # Production PKCS#11 client (CGO) 676 + │ │ └── pkcs11_client_nocgo.go # Stub for testing builds 677 + │ ├── agent/ # Agent deployment and communication 678 + │ │ ├── deployment.go # Agent pod management 679 + │ │ └── client.go # Agent API client 680 + │ ├── api/ # REST API server 681 + │ │ ├── server.go # HTTP server setup 682 + │ │ └── proxy_handlers.go # API proxy to agents 683 + │ └── discovery/ # Device discovery (legacy) 684 + │ ├── usb.go # USB device scanning 685 + │ └── mirroring.go # Cross-node mirroring 686 + ├── examples/ # Usage examples and configurations 687 + │ ├── basic/ # Simple configurations 688 + │ ├── advanced/ # Complex multi-device setups 689 + │ ├── api/ # API usage scripts 690 + │ └── agent-deployment/ # Agent-specific examples 691 + ├── config/ # Kubernetes manifests 692 + │ ├── crd/bases/ # Generated CRD definitions 693 + │ ├── rbac/ # Generated RBAC rules 694 + │ ├── samples/ # Sample resource configurations 695 + │ └── default/ # Default deployment configuration 696 + ├── helm/ # Helm chart 697 + │ └── hsm-secrets-operator/ # Complete Helm chart 698 + └── test/ # Test suites 699 + ├── e2e/ # End-to-end tests 700 + └── utils/ # Test utilities 644 701 ``` 645 702 646 703 ## Technical Requirements ··· 688 745 ```bash 689 746 # List all secrets (requires PIN authentication) 690 747 pkcs11-tool --module="/usr/lib/opensc-pkcs11.so" \ 691 - --login --pin="YOUR_PIN" \ 748 + --login --pin=$PKCS11_PIN \ 692 749 --list-objects --type=data 693 750 694 751 # List public objects only (no PIN required) ··· 697 754 698 755 # Read a specific secret component 699 756 pkcs11-tool --module="/usr/lib/opensc-pkcs11.so" \ 700 - --login --pin="YOUR_PIN" \ 757 + --login --pin=$PKCS11_PIN \ 701 758 --read-object --type=data --label="secret-name/api_key" 702 759 703 760 # Get HSM info ··· 705 762 706 763 # List all object types 707 764 pkcs11-tool --module="/usr/lib/opensc-pkcs11.so" \ 708 - --login --pin="YOUR_PIN" \ 765 + --login --pin=$PKCS11_PIN \ 709 766 --list-objects 710 767 ``` 711 768
+65 -1
examples/api/advanced-bulk-import.sh
··· 34 34 echo -e "${YELLOW}⚠️${NC} $1" 35 35 } 36 36 37 + # Convert Bitwarden vault format to HSM format 38 + convert_bitwarden_vault() { 39 + local input_file="$1" 40 + local output_file="${input_file%.json}-hsm.json" 41 + 42 + log "Converting Bitwarden vault format to HSM format..." 43 + 44 + # Check if this is a Bitwarden vault file 45 + if jq -e '.projects and .secrets' "$input_file" > /dev/null 2>&1; then 46 + log "Detected Bitwarden vault format" 47 + 48 + # Build project name mapping 49 + local project_map=$(mktemp) 50 + jq -r '.projects[] | "\(.id) \(.name)"' "$input_file" > "$project_map" 51 + 52 + # Convert secrets format 53 + jq --slurpfile projects <(jq '.projects' "$input_file") ' 54 + { 55 + "secrets": [ 56 + .secrets[] | { 57 + "label": .key, 58 + "id": (.id | gsub("-"; "") | .[0:8]), 59 + "format": "text", 60 + "description": (if .note != "" then .note else "Imported from Bitwarden vault" end), 61 + "tags": { 62 + "source": "bitwarden", 63 + "projects": [.projectIds[] as $pid | $projects[0][] | select(.id == $pid) | .name] 64 + }, 65 + "metadata": { 66 + "label": .key, 67 + "description": (if .note != "" then .note else "Imported from Bitwarden vault" end), 68 + "tags": { 69 + "source": "bitwarden", 70 + "projects": [.projectIds[] as $pid | $projects[0][] | select(.id == $pid) | .name] 71 + }, 72 + "format": "text", 73 + "dataType": "plaintext", 74 + "createdAt": now | strftime("%Y-%m-%dT%H:%M:%SZ"), 75 + "source": "bitwarden" 76 + }, 77 + "data": { 78 + "value": .value 79 + } 80 + } 81 + ] 82 + }' "$input_file" > "$output_file" 83 + 84 + rm "$project_map" 85 + success "Converted Bitwarden vault to: $output_file" 86 + CONFIG_FILE="$output_file" 87 + else 88 + log "Not a Bitwarden vault format, proceeding with original file" 89 + fi 90 + } 91 + 37 92 # Validate prerequisites 38 93 validate_prerequisites() { 39 94 log "Validating prerequisites..." ··· 57 112 error "Invalid JSON in config file: $CONFIG_FILE" 58 113 exit 1 59 114 fi 115 + 116 + # Convert Bitwarden format if detected 117 + convert_bitwarden_vault "$CONFIG_FILE" 60 118 61 119 # Test API connectivity 62 120 if ! curl -s --connect-timeout 5 "$API_BASE_URL/api/v1/health" > /dev/null; then ··· 171 229 response=$(curl -s -X POST \ 172 230 -H "Content-Type: application/json" \ 173 231 -d "$secret_data" \ 174 - "$API_BASE_URL/api/v1/hsm/secrets" 2>/dev/null) 232 + "$API_BASE_URL/api/v1/hsm/secrets/$label" 2>/dev/null) 175 233 176 234 if [ $? -ne 0 ]; then 177 235 error "Failed to connect to API for $label" ··· 302 360 --help) 303 361 echo "Usage: $0 [config-file] [options]" 304 362 echo "" 363 + echo "Supports both HSM format and Bitwarden vault format (auto-detected)." 364 + echo "" 305 365 echo "Options:" 306 366 echo " --dry-run Show what would be imported without making changes" 307 367 echo " --no-rollback Don't rollback on failure" 308 368 echo " --api-url URL Override API base URL" 309 369 echo " --help Show this help message" 370 + echo "" 371 + echo "Config file formats:" 372 + echo " HSM format: Standard format with 'secrets' array" 373 + echo " Bitwarden: Vault export with 'projects' and 'secrets' arrays" 310 374 echo "" 311 375 echo "Environment variables:" 312 376 echo " API_BASE_URL API endpoint (default: http://localhost:8090)"
+23
examples/api/bitwarden-vault.json
··· 1 + { 2 + "projects": [ 3 + { 4 + "id": "a9501a2c-1984-435a-90b8-b20f016554c2", 5 + "name": "kubernetes" 6 + }, 7 + { 8 + "id": "b1234567-8901-2345-6789-012345678901", 9 + "name": "web-services" 10 + } 11 + ], 12 + "secrets": [ 13 + { 14 + "key": "CLOUDFLARE_API", 15 + "value": "my_cf_api_token", 16 + "note": "Cloudflare API token for DNS management", 17 + "id": "0dc93e40-9155-4a7a-a809-b20f0165daa4", 18 + "projectIds": [ 19 + "a9501a2c-1984-435a-90b8-b20f016554c2" 20 + ] 21 + } 22 + ] 23 + }
+125
examples/api/delete-secret.sh
··· 1 + #!/bin/bash 2 + 3 + # Delete HSM Secret via REST API 4 + # Usage: ./delete-secret.sh [secret-name] [options] 5 + 6 + set -e 7 + 8 + API_BASE_URL=${API_BASE_URL:-"http://localhost:8090"} 9 + SECRET_NAME="$1" 10 + FORCE=${FORCE:-false} 11 + 12 + # Colors for output 13 + RED='\033[0;31m' 14 + GREEN='\033[0;32m' 15 + YELLOW='\033[1;33m' 16 + BLUE='\033[0;34m' 17 + NC='\033[0m' # No Color 18 + 19 + # Check if secret name is provided 20 + if [ -z "$SECRET_NAME" ]; then 21 + echo "Usage: $0 <secret-name> [--force]" 22 + echo "" 23 + echo "Options:" 24 + echo " --force Skip confirmation prompt" 25 + echo "" 26 + echo "Environment variables:" 27 + echo " API_BASE_URL API endpoint (default: http://localhost:8090)" 28 + echo " FORCE Skip confirmation (default: false)" 29 + echo "" 30 + echo "Examples:" 31 + echo " $0 my-secret" 32 + echo " $0 my-secret --force" 33 + echo " FORCE=true $0 my-secret" 34 + exit 1 35 + fi 36 + 37 + # Parse command line options 38 + while [[ $# -gt 1 ]]; do 39 + case $2 in 40 + --force) 41 + FORCE=true 42 + shift 43 + ;; 44 + *) 45 + echo "Unknown option: $2" 46 + exit 1 47 + ;; 48 + esac 49 + done 50 + 51 + echo -e "${RED}🗑️ Deleting HSM Secret via API...${NC}" 52 + echo "Secret Name: $SECRET_NAME" 53 + echo "API Base URL: $API_BASE_URL" 54 + echo "" 55 + 56 + # First, check if the secret exists 57 + echo -e "${BLUE}🔍 Checking if secret exists...${NC}" 58 + check_response=$(curl -s "$API_BASE_URL/api/v1/hsm/secrets/$SECRET_NAME") 59 + check_success=$(echo "$check_response" | jq -r '.success') 60 + 61 + if [ "$check_success" != "true" ]; then 62 + echo -e "${YELLOW}⚠️ Secret '$SECRET_NAME' not found${NC}" 63 + error_message=$(echo "$check_response" | jq -r '.error.message // "No error message"') 64 + echo " Error: $error_message" 65 + exit 1 66 + fi 67 + 68 + echo -e "${GREEN}✅ Secret found${NC}" 69 + 70 + # Show secret details 71 + echo "" 72 + echo -e "${BLUE}📋 Secret Details:${NC}" 73 + echo "$check_response" | jq -r '.data | to_entries[] | " \(.key): \(.value)"' 74 + 75 + # Confirmation prompt (unless --force is used) 76 + if [ "$FORCE" != "true" ]; then 77 + echo "" 78 + echo -e "${YELLOW}⚠️ This action cannot be undone!${NC}" 79 + read -p "Are you sure you want to delete secret '$SECRET_NAME'? (y/N): " -n 1 -r 80 + echo "" 81 + if [[ ! $REPLY =~ ^[Yy]$ ]]; then 82 + echo "❌ Delete cancelled by user" 83 + exit 0 84 + fi 85 + fi 86 + 87 + # Make the delete API call 88 + echo "" 89 + echo -e "${BLUE}📤 Sending delete request...${NC}" 90 + response=$(curl -s -X DELETE "$API_BASE_URL/api/v1/hsm/secrets/$SECRET_NAME") 91 + 92 + echo -e "${BLUE}📥 Response:${NC}" 93 + echo "$response" | jq '.' 94 + 95 + # Check if the request was successful 96 + success=$(echo "$response" | jq -r '.success') 97 + if [ "$success" = "true" ]; then 98 + echo "" 99 + echo -e "${GREEN}✅ Secret deleted successfully!${NC}" 100 + 101 + # Extract deletion info 102 + path=$(echo "$response" | jq -r '.data.path // "unknown"') 103 + message=$(echo "$response" | jq -r '.message // "Secret deleted"') 104 + 105 + echo " Secret Name: $SECRET_NAME" 106 + echo " HSM Path: $path" 107 + echo " Status: $message" 108 + 109 + echo "" 110 + echo -e "${BLUE}📋 To verify deletion:${NC}" 111 + echo " curl $API_BASE_URL/api/v1/hsm/secrets/$SECRET_NAME" 112 + 113 + echo "" 114 + echo -e "${BLUE}📋 To list remaining secrets:${NC}" 115 + echo " curl $API_BASE_URL/api/v1/hsm/secrets" 116 + 117 + else 118 + echo "" 119 + echo -e "${RED}❌ Failed to delete secret!${NC}" 120 + error_code=$(echo "$response" | jq -r '.error.code // "unknown"') 121 + error_message=$(echo "$response" | jq -r '.error.message // "No error message"') 122 + echo " Error Code: $error_code" 123 + echo " Error Message: $error_message" 124 + exit 1 125 + fi
+11 -22
examples/api/list-secrets.sh
··· 20 20 # Check if the request was successful 21 21 success=$(echo "$response" | jq -r '.success') 22 22 if [ "$success" = "true" ]; then 23 - # Extract pagination info 24 - total=$(echo "$response" | jq -r '.data.total') 25 - current_page=$(echo "$response" | jq -r '.data.page') 26 - page_size=$(echo "$response" | jq -r '.data.page_size') 23 + # Extract secret info 24 + count=$(echo "$response" | jq -r '.data.count') 25 + prefix=$(echo "$response" | jq -r '.data.prefix // ""') 27 26 28 27 echo "📊 Summary:" 29 - echo " Total Secrets: $total" 30 - echo " Current Page: $current_page" 31 - echo " Page Size: $page_size" 28 + echo " Total Secrets: $count" 29 + if [ -n "$prefix" ] && [ "$prefix" != "" ]; then 30 + echo " Prefix Filter: $prefix" 31 + fi 32 32 echo "" 33 33 34 34 # List secrets 35 35 echo "🔐 Secrets:" 36 - echo "$response" | jq -r '.data.secrets[] | " • \(.label) (ID: \(.id)) - Updated: \(.updated_at // "N/A")"' 37 - 38 - # Show detailed table if there are secrets 39 - if [ "$total" -gt 0 ]; then 40 - echo "" 41 - echo "📋 Detailed List:" 42 - echo "$response" | jq -r ' 43 - .data.secrets | 44 - ["Label", "ID", "Checksum", "Replicated", "Updated"] as $headers | 45 - $headers, 46 - (["-----", "---", "--------", "---------", "-------"]) as $separators | 47 - $separators, 48 - (.[] | [.label, (.id // "N/A"), (.checksum[0:8] // "N/A"), .is_replicated, (.updated_at[0:10] // "N/A")]) | 49 - @tsv 50 - ' | column -t 36 + if [ "$count" -gt 0 ]; then 37 + echo "$response" | jq -r '.data.paths[] | " • \(.)"' 38 + else 39 + echo " No secrets found" 51 40 fi 52 41 else 53 42 echo "❌ Failed to list secrets!"
+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.4.4 6 - appVersion: v0.4.4 5 + version: 0.4.5 6 + appVersion: v0.4.5 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:
+24
internal/agent/client.go
··· 393 393 c.timeout = timeout 394 394 c.httpClient.Timeout = timeout 395 395 } 396 + 397 + // WriteSecretWithMetadata writes secret data and metadata to the specified HSM path 398 + func (c *Client) WriteSecretWithMetadata(ctx context.Context, path string, data hsm.SecretData, metadata *hsm.SecretMetadata) error { 399 + // For now, just write the secret data - metadata support can be added to agent API later 400 + // This provides compatibility with the updated interface 401 + if err := c.WriteSecret(ctx, path, data); err != nil { 402 + return err 403 + } 404 + 405 + // TODO: Add metadata storage to agent API endpoints 406 + if metadata != nil { 407 + c.logger.V(1).Info("Metadata not yet supported in agent API, skipping", "path", path) 408 + } 409 + 410 + return nil 411 + } 412 + 413 + // ReadMetadata reads metadata for a secret at the given path 414 + func (c *Client) ReadMetadata(ctx context.Context, path string) (*hsm.SecretMetadata, error) { 415 + // TODO: Add metadata reading from agent API endpoints 416 + // For now, return empty metadata to satisfy interface 417 + c.logger.V(1).Info("Metadata reading not yet supported in agent API", "path", path) 418 + return nil, fmt.Errorf("metadata not found for path: %s (agent API doesn't support metadata yet)", path) 419 + }
+17
internal/hsm/client.go
··· 35 35 FirmwareVersion string 36 36 } 37 37 38 + // SecretMetadata contains metadata about an HSM secret 39 + type SecretMetadata struct { 40 + Label string `json:"label,omitempty"` 41 + Description string `json:"description,omitempty"` 42 + Tags map[string]string `json:"tags,omitempty"` 43 + Format string `json:"format,omitempty"` 44 + DataType SecretDataType `json:"dataType,omitempty"` 45 + CreatedAt string `json:"createdAt,omitempty"` 46 + Source string `json:"source,omitempty"` 47 + } 48 + 38 49 // Client defines the interface for HSM operations 39 50 type Client interface { 40 51 // Initialize establishes connection to the HSM ··· 51 62 52 63 // WriteSecret writes secret data to the specified HSM path 53 64 WriteSecret(ctx context.Context, path string, data SecretData) error 65 + 66 + // WriteSecretWithMetadata writes secret data and metadata to the specified HSM path 67 + WriteSecretWithMetadata(ctx context.Context, path string, data SecretData, metadata *SecretMetadata) error 68 + 69 + // ReadMetadata reads metadata for a secret at the given path 70 + ReadMetadata(ctx context.Context, path string) (*SecretMetadata, error) 54 71 55 72 // DeleteSecret removes secret data from the specified HSM path 56 73 DeleteSecret(ctx context.Context, path string) error
+38 -2
internal/hsm/mock_client.go
··· 32 32 mutex sync.RWMutex 33 33 connected bool 34 34 secrets map[string]SecretData 35 + metadata map[string]*SecretMetadata 35 36 config Config 36 37 } 37 38 38 39 // NewMockClient creates a new mock HSM client for testing 39 40 func NewMockClient() *MockClient { 40 41 return &MockClient{ 41 - logger: ctrl.Log.WithName("hsm-mock-client"), 42 - secrets: make(map[string]SecretData), 42 + logger: ctrl.Log.WithName("hsm-mock-client"), 43 + secrets: make(map[string]SecretData), 44 + metadata: make(map[string]*SecretMetadata), 43 45 } 44 46 } 45 47 ··· 142 144 return nil 143 145 } 144 146 147 + // WriteSecretWithMetadata writes secret data and metadata to the specified HSM path 148 + func (m *MockClient) WriteSecretWithMetadata(ctx context.Context, path string, data SecretData, metadata *SecretMetadata) error { 149 + if err := m.WriteSecret(ctx, path, data); err != nil { 150 + return err 151 + } 152 + 153 + if metadata != nil { 154 + m.mutex.Lock() 155 + defer m.mutex.Unlock() 156 + m.metadata[path] = metadata 157 + m.logger.V(1).Info("Wrote metadata to mock HSM", "path", path) 158 + } 159 + 160 + return nil 161 + } 162 + 163 + // ReadMetadata reads metadata for a secret at the given path 164 + func (m *MockClient) ReadMetadata(ctx context.Context, path string) (*SecretMetadata, error) { 165 + m.mutex.RLock() 166 + defer m.mutex.RUnlock() 167 + 168 + if !m.connected { 169 + return nil, fmt.Errorf("HSM not connected") 170 + } 171 + 172 + metadata, exists := m.metadata[path] 173 + if !exists { 174 + return nil, fmt.Errorf("metadata not found for path: %s", path) 175 + } 176 + 177 + return metadata, nil 178 + } 179 + 145 180 // DeleteSecret removes secret data from mock storage 146 181 func (m *MockClient) DeleteSecret(ctx context.Context, path string) error { 147 182 m.mutex.Lock() ··· 156 191 } 157 192 158 193 delete(m.secrets, path) 194 + delete(m.metadata, path) // Also delete metadata 159 195 m.logger.Info("Deleted secret from mock HSM", "path", path) 160 196 return nil 161 197 }
+148
internal/hsm/oids.go
··· 1 + package hsm 2 + 3 + import ( 4 + "encoding/asn1" 5 + "fmt" 6 + ) 7 + 8 + // HSM Secrets Operator OID namespace 9 + // Using experimental/private range: 1.3.6.1.4.1.99999 (not officially registered) 10 + // In production, this should be replaced with a properly registered enterprise OID 11 + 12 + var ( 13 + // Data type OIDs 14 + OIDPlaintext = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 99999, 1, 1} // Plain text secrets 15 + OIDJson = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 99999, 1, 2} // JSON configuration 16 + OIDPem = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 99999, 1, 3} // PEM encoded certificates/keys 17 + OIDBinary = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 99999, 1, 4} // Binary data 18 + OIDBase64 = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 99999, 1, 5} // Base64 encoded data 19 + OIDX509Cert = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 99999, 1, 6} // X.509 certificate 20 + OIDPrivKey = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 99999, 1, 7} // Private key material 21 + OIDDockerCfg = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 99999, 1, 8} // Docker config JSON 22 + ) 23 + 24 + // SecretDataType represents the type of data stored in HSM 25 + type SecretDataType string 26 + 27 + const ( 28 + DataTypePlaintext SecretDataType = "plaintext" 29 + DataTypeJson SecretDataType = "json" 30 + DataTypePem SecretDataType = "pem" 31 + DataTypeBinary SecretDataType = "binary" 32 + DataTypeBase64 SecretDataType = "base64" 33 + DataTypeX509Cert SecretDataType = "x509-cert" 34 + DataTypePrivKey SecretDataType = "private-key" 35 + DataTypeDockerCfg SecretDataType = "docker-config" 36 + ) 37 + 38 + // GetOIDForDataType returns the OID for a given data type 39 + func GetOIDForDataType(dataType SecretDataType) (asn1.ObjectIdentifier, error) { 40 + switch dataType { 41 + case DataTypePlaintext: 42 + return OIDPlaintext, nil 43 + case DataTypeJson: 44 + return OIDJson, nil 45 + case DataTypePem: 46 + return OIDPem, nil 47 + case DataTypeBinary: 48 + return OIDBinary, nil 49 + case DataTypeBase64: 50 + return OIDBase64, nil 51 + case DataTypeX509Cert: 52 + return OIDX509Cert, nil 53 + case DataTypePrivKey: 54 + return OIDPrivKey, nil 55 + case DataTypeDockerCfg: 56 + return OIDDockerCfg, nil 57 + default: 58 + return nil, fmt.Errorf("unknown data type: %s", dataType) 59 + } 60 + } 61 + 62 + // GetDataTypeForOID returns the data type for a given OID 63 + func GetDataTypeForOID(oid asn1.ObjectIdentifier) (SecretDataType, error) { 64 + switch { 65 + case oid.Equal(OIDPlaintext): 66 + return DataTypePlaintext, nil 67 + case oid.Equal(OIDJson): 68 + return DataTypeJson, nil 69 + case oid.Equal(OIDPem): 70 + return DataTypePem, nil 71 + case oid.Equal(OIDBinary): 72 + return DataTypeBinary, nil 73 + case oid.Equal(OIDBase64): 74 + return DataTypeBase64, nil 75 + case oid.Equal(OIDX509Cert): 76 + return DataTypeX509Cert, nil 77 + case oid.Equal(OIDPrivKey): 78 + return DataTypePrivKey, nil 79 + case oid.Equal(OIDDockerCfg): 80 + return DataTypeDockerCfg, nil 81 + default: 82 + return "", fmt.Errorf("unknown OID: %s", oid.String()) 83 + } 84 + } 85 + 86 + // EncodeDER returns the DER encoding of an OID for use in PKCS#11 CKA_OBJECT_ID 87 + func EncodeDER(oid asn1.ObjectIdentifier) ([]byte, error) { 88 + return asn1.Marshal(oid) 89 + } 90 + 91 + // DecodeDER decodes a DER-encoded OID from PKCS#11 CKA_OBJECT_ID 92 + func DecodeDER(der []byte) (asn1.ObjectIdentifier, error) { 93 + var oid asn1.ObjectIdentifier 94 + _, err := asn1.Unmarshal(der, &oid) 95 + return oid, err 96 + } 97 + 98 + // InferDataType attempts to infer the data type from content 99 + func InferDataType(data []byte) SecretDataType { 100 + content := string(data) 101 + 102 + // Check for PEM format 103 + if len(content) > 20 && content[:5] == "-----" { 104 + return DataTypePem 105 + } 106 + 107 + // Check for JSON format 108 + if len(content) > 0 && (content[0] == '{' || content[0] == '[') { 109 + return DataTypeJson 110 + } 111 + 112 + // Check if it's valid base64 113 + if isValidBase64(content) { 114 + return DataTypeBase64 115 + } 116 + 117 + // Check for binary data (contains non-printable chars) 118 + for _, b := range data { 119 + if b < 32 && b != 9 && b != 10 && b != 13 { // Allow tab, LF, CR 120 + return DataTypeBinary 121 + } 122 + } 123 + 124 + // Default to plaintext 125 + return DataTypePlaintext 126 + } 127 + 128 + // isValidBase64 checks if a string is valid base64 129 + func isValidBase64(s string) bool { 130 + if len(s) == 0 { 131 + return false 132 + } 133 + 134 + // Base64 strings should be multiple of 4 in length (with padding) 135 + if len(s)%4 != 0 { 136 + return false 137 + } 138 + 139 + // Check for valid base64 characters 140 + for _, c := range s { 141 + if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') && 142 + (c < '0' || c > '9') && c != '+' && c != '/' && c != '=' { 143 + return false 144 + } 145 + } 146 + 147 + return true 148 + }
+153 -2
internal/hsm/pkcs11_client.go
··· 21 21 22 22 import ( 23 23 "context" 24 + "encoding/json" 24 25 "fmt" 25 26 "strings" 26 27 "sync" ··· 32 33 ) 33 34 34 35 const ( 35 - defaultKeyName = "data" 36 + defaultKeyName = "data" 37 + metadataKeySuffix = "/_metadata" 38 + applicationName = "hsm-secrets-operator" 36 39 ) 37 40 38 41 // PKCS11Client implements the Client interface using PKCS#11 ··· 359 362 return data, nil 360 363 } 361 364 365 + // WriteSecretWithMetadata writes secret data and metadata to the specified HSM path 366 + func (c *PKCS11Client) WriteSecretWithMetadata(ctx context.Context, path string, data SecretData, metadata *SecretMetadata) error { 367 + if err := c.WriteSecret(ctx, path, data); err != nil { 368 + return err 369 + } 370 + 371 + if metadata != nil { 372 + return c.writeMetadata(path, metadata) 373 + } 374 + 375 + return nil 376 + } 377 + 362 378 // WriteSecret writes secret data to the specified HSM path 363 379 func (c *PKCS11Client) WriteSecret(ctx context.Context, path string, data SecretData) error { 364 380 c.mutex.Lock() ··· 383 399 label = path + "/" + key 384 400 } 385 401 402 + // Infer data type from content 403 + dataType := InferDataType(value) 404 + 405 + // Get OID for data type 406 + oid, err := GetOIDForDataType(dataType) 407 + if err != nil { 408 + c.logger.V(1).Info("Failed to get OID for data type, using default", 409 + "dataType", dataType, "error", err) 410 + oid = OIDPlaintext // Default fallback 411 + } 412 + 413 + // Encode OID as DER 414 + derOID, err := EncodeDER(oid) 415 + if err != nil { 416 + c.logger.V(1).Info("Failed to encode OID as DER", "error", err) 417 + derOID = nil // Will skip CKA_OBJECT_ID if encoding fails 418 + } 419 + 420 + // Build template with proper attributes 386 421 template := []*pkcs11.Attribute{ 387 422 pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_DATA), 388 423 pkcs11.NewAttribute(pkcs11.CKA_LABEL, label), 424 + pkcs11.NewAttribute(pkcs11.CKA_APPLICATION, applicationName), // Proper application name 389 425 pkcs11.NewAttribute(pkcs11.CKA_VALUE, value), 390 426 pkcs11.NewAttribute(pkcs11.CKA_TOKEN, true), // Store persistently 391 427 pkcs11.NewAttribute(pkcs11.CKA_PRIVATE, true), // Require authentication 392 428 pkcs11.NewAttribute(pkcs11.CKA_MODIFIABLE, true), // Allow updates 429 + } 430 + 431 + // Add OID if we successfully encoded it 432 + if derOID != nil { 433 + template = append(template, pkcs11.NewAttribute(pkcs11.CKA_OBJECT_ID, derOID)) 393 434 } 394 435 395 436 obj, err := c.ctx.CreateObject(c.session, template) ··· 400 441 // Cache the object handle for faster future lookups 401 442 c.dataObjects[label] = obj 402 443 403 - c.logger.V(2).Info("Created data object", "path", path, "key", key, "label", label) 444 + c.logger.V(2).Info("Created data object", "path", path, "key", key, "label", label, "dataType", dataType) 404 445 } 405 446 406 447 c.logger.Info("Successfully wrote secret to HSM", "path", path) 407 448 return nil 449 + } 450 + 451 + // writeMetadata creates a metadata object for the secret 452 + func (c *PKCS11Client) writeMetadata(path string, metadata *SecretMetadata) error { 453 + // Serialize metadata to JSON 454 + metadataJSON, err := json.Marshal(metadata) 455 + if err != nil { 456 + return fmt.Errorf("failed to serialize metadata: %w", err) 457 + } 458 + 459 + // Create metadata object label 460 + metadataLabel := path + metadataKeySuffix 461 + 462 + // Get OID for JSON data type 463 + oid, err := GetOIDForDataType(DataTypeJson) 464 + if err != nil { 465 + c.logger.V(1).Info("Failed to get OID for JSON metadata", "error", err) 466 + oid = OIDJson // Fallback 467 + } 468 + 469 + // Encode OID as DER 470 + derOID, err := EncodeDER(oid) 471 + if err != nil { 472 + c.logger.V(1).Info("Failed to encode metadata OID as DER", "error", err) 473 + derOID = nil 474 + } 475 + 476 + // Build metadata object template 477 + template := []*pkcs11.Attribute{ 478 + pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_DATA), 479 + pkcs11.NewAttribute(pkcs11.CKA_LABEL, metadataLabel), 480 + pkcs11.NewAttribute(pkcs11.CKA_APPLICATION, applicationName), 481 + pkcs11.NewAttribute(pkcs11.CKA_VALUE, metadataJSON), 482 + pkcs11.NewAttribute(pkcs11.CKA_TOKEN, true), // Store persistently 483 + pkcs11.NewAttribute(pkcs11.CKA_PRIVATE, true), // Require authentication 484 + pkcs11.NewAttribute(pkcs11.CKA_MODIFIABLE, true), // Allow updates 485 + } 486 + 487 + // Add OID if we successfully encoded it 488 + if derOID != nil { 489 + template = append(template, pkcs11.NewAttribute(pkcs11.CKA_OBJECT_ID, derOID)) 490 + } 491 + 492 + // Create the metadata object 493 + obj, err := c.ctx.CreateObject(c.session, template) 494 + if err != nil { 495 + return fmt.Errorf("failed to create metadata object: %w", err) 496 + } 497 + 498 + // Cache the metadata object handle 499 + c.dataObjects[metadataLabel] = obj 500 + 501 + c.logger.V(2).Info("Created metadata object", "path", path, "label", metadataLabel) 502 + return nil 503 + } 504 + 505 + // ReadMetadata reads metadata for a secret at the given path 506 + func (c *PKCS11Client) ReadMetadata(ctx context.Context, path string) (*SecretMetadata, error) { 507 + c.mutex.RLock() 508 + defer c.mutex.RUnlock() 509 + 510 + if !c.connected { 511 + return nil, fmt.Errorf("HSM not connected") 512 + } 513 + 514 + metadataLabel := path + metadataKeySuffix 515 + 516 + // Find the metadata object 517 + template := []*pkcs11.Attribute{ 518 + pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_DATA), 519 + pkcs11.NewAttribute(pkcs11.CKA_LABEL, metadataLabel), 520 + } 521 + 522 + if err := c.ctx.FindObjectsInit(c.session, template); err != nil { 523 + return nil, fmt.Errorf("failed to initialize metadata search: %w", err) 524 + } 525 + defer func() { 526 + if finalErr := c.ctx.FindObjectsFinal(c.session); finalErr != nil { 527 + c.logger.V(1).Info("Failed to finalize metadata search", "error", finalErr) 528 + } 529 + }() 530 + 531 + objs, _, err := c.ctx.FindObjects(c.session, 1) 532 + if err != nil { 533 + return nil, fmt.Errorf("failed to find metadata object: %w", err) 534 + } 535 + 536 + if len(objs) == 0 { 537 + return nil, fmt.Errorf("metadata not found for path: %s", path) 538 + } 539 + 540 + // Get the metadata value 541 + valueAttr, err := c.ctx.GetAttributeValue(c.session, objs[0], []*pkcs11.Attribute{ 542 + pkcs11.NewAttribute(pkcs11.CKA_VALUE, nil), 543 + }) 544 + if err != nil { 545 + return nil, fmt.Errorf("failed to get metadata value: %w", err) 546 + } 547 + 548 + if len(valueAttr) == 0 || len(valueAttr[0].Value) == 0 { 549 + return nil, fmt.Errorf("metadata object has no value") 550 + } 551 + 552 + // Parse the JSON metadata 553 + var metadata SecretMetadata 554 + if err := json.Unmarshal(valueAttr[0].Value, &metadata); err != nil { 555 + return nil, fmt.Errorf("failed to parse metadata JSON: %w", err) 556 + } 557 + 558 + return &metadata, nil 408 559 } 409 560 410 561 // deleteSecretObjects removes all data objects matching the given path prefix