up
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Build Test Deploy / docs (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / deploy (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / build-test (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / authority-container (push) Has been cancelled
				
			
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Build Test Deploy / docs (push) Has been cancelled
				
			Build Test Deploy / deploy (push) Has been cancelled
				
			Build Test Deploy / build-test (push) Has been cancelled
				
			Build Test Deploy / authority-container (push) Has been cancelled
				
			Docs CI / lint-and-preview (push) Has been cancelled
				
			This commit is contained in:
		
							
								
								
									
										189
									
								
								ops/authority/key-rotation.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								ops/authority/key-rotation.sh
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,189 @@ | ||||
| #!/usr/bin/env bash | ||||
|  | ||||
| set -euo pipefail | ||||
|  | ||||
| usage() { | ||||
|   cat <<'USAGE' | ||||
| Usage: key-rotation.sh --authority-url URL --api-key TOKEN --key-id ID --key-path PATH [options] | ||||
|  | ||||
| Required flags: | ||||
|   -u, --authority-url   Base Authority URL (e.g. https://authority.example.com) | ||||
|   -k, --api-key         Bootstrap API key (x-stellaops-bootstrap-key header) | ||||
|   -i, --key-id          Identifier (kid) for the new signing key | ||||
|   -p, --key-path        Path (relative to Authority content root or absolute) where the PEM key lives | ||||
|  | ||||
| Optional flags: | ||||
|   -s, --source          Key source loader identifier (default: file) | ||||
|   -a, --algorithm       Signing algorithm (default: ES256) | ||||
|       --provider        Preferred crypto provider name | ||||
|   -m, --meta key=value  Additional metadata entries for the rotation record (repeatable) | ||||
|       --dry-run         Print the JSON payload instead of invoking the API | ||||
|   -h, --help            Show this help | ||||
|  | ||||
| Environment fallbacks: | ||||
|   AUTHORITY_URL, AUTHORITY_BOOTSTRAP_KEY, AUTHORITY_KEY_SOURCE, AUTHORITY_KEY_PROVIDER | ||||
|  | ||||
| Example: | ||||
|   AUTHORITY_BOOTSTRAP_KEY=$(cat key.txt) \\ | ||||
|     ./key-rotation.sh -u https://authority.local \\ | ||||
|       -i authority-signing-2025 \\ | ||||
|       -p ../certificates/authority-signing-2025.pem \\ | ||||
|       -m rotatedBy=pipeline -m ticket=OPS-1234 | ||||
| USAGE | ||||
| } | ||||
|  | ||||
| require_python() { | ||||
|   if command -v python3 >/dev/null 2>&1; then | ||||
|     PYTHON_BIN=python3 | ||||
|   elif command -v python >/dev/null 2>&1; then | ||||
|     PYTHON_BIN=python | ||||
|   else | ||||
|     echo "error: python3 (or python) is required for JSON encoding" >&2 | ||||
|     exit 1 | ||||
|   fi | ||||
| } | ||||
|  | ||||
| json_quote() { | ||||
|   "$PYTHON_BIN" - "$1" <<'PY' | ||||
| import json, sys | ||||
| print(json.dumps(sys.argv[1])) | ||||
| PY | ||||
| } | ||||
|  | ||||
| AUTHORITY_URL="${AUTHORITY_URL:-}" | ||||
| API_KEY="${AUTHORITY_BOOTSTRAP_KEY:-}" | ||||
| KEY_ID="" | ||||
| KEY_PATH="" | ||||
| SOURCE="${AUTHORITY_KEY_SOURCE:-file}" | ||||
| ALGORITHM="ES256" | ||||
| PROVIDER="${AUTHORITY_KEY_PROVIDER:-}" | ||||
| DRY_RUN=false | ||||
| declare -a METADATA=() | ||||
|  | ||||
| while [[ $# -gt 0 ]]; do | ||||
|   case "$1" in | ||||
|     -u|--authority-url) | ||||
|       AUTHORITY_URL="$2" | ||||
|       shift 2 | ||||
|       ;; | ||||
|     -k|--api-key) | ||||
|       API_KEY="$2" | ||||
|       shift 2 | ||||
|       ;; | ||||
|     -i|--key-id) | ||||
|       KEY_ID="$2" | ||||
|       shift 2 | ||||
|       ;; | ||||
|     -p|--key-path) | ||||
|       KEY_PATH="$2" | ||||
|       shift 2 | ||||
|       ;; | ||||
|     -s|--source) | ||||
|       SOURCE="$2" | ||||
|       shift 2 | ||||
|       ;; | ||||
|     -a|--algorithm) | ||||
|       ALGORITHM="$2" | ||||
|       shift 2 | ||||
|       ;; | ||||
|     --provider) | ||||
|       PROVIDER="$2" | ||||
|       shift 2 | ||||
|       ;; | ||||
|     -m|--meta) | ||||
|       METADATA+=("$2") | ||||
|       shift 2 | ||||
|       ;; | ||||
|     --dry-run) | ||||
|       DRY_RUN=true | ||||
|       shift | ||||
|       ;; | ||||
|     -h|--help) | ||||
|       usage | ||||
|       exit 0 | ||||
|       ;; | ||||
|     *) | ||||
|       echo "Unknown option: $1" >&2 | ||||
|       usage | ||||
|       exit 1 | ||||
|       ;; | ||||
|   esac | ||||
| done | ||||
|  | ||||
| if [[ -z "$AUTHORITY_URL" || -z "$API_KEY" || -z "$KEY_ID" || -z "$KEY_PATH" ]]; then | ||||
|   echo "error: missing required arguments" >&2 | ||||
|   usage | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| case "$AUTHORITY_URL" in | ||||
|   http://*|https://*) ;; | ||||
|   *) | ||||
|     echo "error: --authority-url must include scheme (http/https)" >&2 | ||||
|     exit 1 | ||||
|     ;; | ||||
| esac | ||||
|  | ||||
| require_python | ||||
|  | ||||
| payload="{" | ||||
| payload+="\"keyId\":$(json_quote "$KEY_ID")," | ||||
| payload+="\"location\":$(json_quote "$KEY_PATH")," | ||||
| payload+="\"source\":$(json_quote "$SOURCE")," | ||||
| payload+="\"algorithm\":$(json_quote "$ALGORITHM")," | ||||
| if [[ -n "$PROVIDER" ]]; then | ||||
|   payload+="\"provider\":$(json_quote "$PROVIDER")," | ||||
| fi | ||||
|  | ||||
| if [[ ${#METADATA[@]} -gt 0 ]]; then | ||||
|   payload+="\"metadata\":{" | ||||
|   for entry in "${METADATA[@]}"; do | ||||
|     if [[ "$entry" != *=* ]]; then | ||||
|       echo "warning: ignoring metadata entry '$entry' (expected key=value)" >&2 | ||||
|       continue | ||||
|     fi | ||||
|     key="${entry%%=*}" | ||||
|     value="${entry#*=}" | ||||
|     payload+="$(json_quote "$key"):$(json_quote "$value")," | ||||
|   done | ||||
|   if [[ "${payload: -1}" == "," ]]; then | ||||
|     payload="${payload::-1}" | ||||
|   fi | ||||
|   payload+="}," | ||||
| fi | ||||
|  | ||||
| if [[ "${payload: -1}" == "," ]]; then | ||||
|   payload="${payload::-1}" | ||||
| fi | ||||
| payload+="}" | ||||
|  | ||||
| if [[ "$DRY_RUN" == true ]]; then | ||||
|   echo "# Dry run payload:" | ||||
|   echo "$payload" | ||||
|   exit 0 | ||||
| fi | ||||
|  | ||||
| tmp_response="$(mktemp)" | ||||
| cleanup() { rm -f "$tmp_response"; } | ||||
| trap cleanup EXIT | ||||
|  | ||||
| http_code=$(curl -sS -o "$tmp_response" -w "%{http_code}" \ | ||||
|   -X POST "${AUTHORITY_URL%/}/internal/signing/rotate" \ | ||||
|   -H "Content-Type: application/json" \ | ||||
|   -H "x-stellaops-bootstrap-key: $API_KEY" \ | ||||
|   --data "$payload") | ||||
|  | ||||
| if [[ "$http_code" != "200" && "$http_code" != "201" ]]; then | ||||
|   echo "error: rotation API returned HTTP $http_code" >&2 | ||||
|   cat "$tmp_response" >&2 || true | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| echo "Rotation request accepted (HTTP $http_code). Response:" | ||||
| cat "$tmp_response" | ||||
|  | ||||
| echo | ||||
| echo "Fetching JWKS to confirm active key..." | ||||
| curl -sS "${AUTHORITY_URL%/}/jwks" || true | ||||
| echo | ||||
| echo "Done. Remember to update authority.yaml with the new key metadata to keep restarts consistent." | ||||
		Reference in New Issue
	
	Block a user