#!/usr/bin/env nu # Rotate Age keys and re-encrypt all SOPS files # Generates new Age keypair in vault-service, then re-encrypts all SOPS files # Usage: secrets-rotate-keys [--environment dev|staging|prod] [--pattern ] use std log def get-vault-url [] { $env.VAULT_SERVICE_URL? // "http://localhost:9094" } def get-vault-token [] { $env.VAULT_SERVICE_TOKEN? // "" } def rotate-keypair-in-vault [environment: string] { let url = $"(get-vault-url)/api/v1/age/rotate?env=($environment)" let token = (get-vault-token) if ($token | is-empty) { error make { msg: "VAULT_SERVICE_TOKEN required for key rotation" label: { text: "Set environment variable" span: (metadata $environment).span } } } print "🔑 Rotating Age keypair in vault-service..." let response = (http post -H {"X-Vault-Token": $token} $url {} | complete) if $response.exit_code != 0 { error make { msg: "Failed to rotate keypair" label: { text: "vault-service rejected request" span: (metadata $environment).span } } } let json = ($response.stdout | from json) print $"✓ Rotated: version ($json.previous_version) → ($json.new_version)" $json } def find-sops-files [environment: string, pattern: string] { let glob_pattern = if ($pattern | is-empty) { $"config/secrets/($environment)/**/*.yaml" } else { $pattern } let files = (glob $glob_pattern | sort) if ($files | is-empty) { print $"⚠️ No SOPS files found for pattern: ($glob_pattern)" } $files } def update-sops-file [file: string] { print $" Re-encrypting: ($file)..." let result = ( ^sops updatekeys --yes $file | complete ) if $result.exit_code != 0 { let stderr = $result.stderr print $" ❌ Failed: ($stderr)" false } else { print $" ✓ Re-encrypted" true } } def main [ --environment: string = "dev" --pattern: string = "" ] { # Validate environment if $environment not-in ["dev", "staging", "prod"] { error make { msg: "Invalid environment" label: { text: "Must be: dev, staging, or prod" span: (metadata $environment).span } } } print $"====== Age Key Rotation for ($environment) ======" print "" # Step 1: Rotate keypair let rotation = (rotate-keypair-in-vault $environment) print "" print "🔄 Re-encrypting SOPS files with new public key..." # Step 2: Find SOPS files let sops_files = (find-sops-files $environment $pattern) if ($sops_files | is-empty) { print "⚠️ No SOPS files to re-encrypt" return { success: true rotated: true files_updated: 0 message: "Key rotated but no files found to re-encrypt" } } print $"Found ($sops_files | length) files to re-encrypt" print "" # Step 3: Re-encrypt each file let success_count = ( $sops_files | map {|file| update-sops-file $file } | where {|result| $result} | length ) let fail_count = ( $sops_files | map {|file| update-sops-file $file } | where {|result| not $result} | length ) print "" print "====== Rotation Complete ======" print $"✓ Success: ($success_count) files" if ($fail_count > 0) { print $"❌ Failed: ($fail_count) files" } print "" if ($fail_count > 0) { error make { msg: "Some files failed to re-encrypt" label: { text: "Review errors above and retry manually" span: (metadata $environment).span } } } { success: true environment: $environment rotated: true previous_version: $rotation.previous_version new_version: $rotation.new_version files_updated: $success_count files_failed: $fail_count next_steps: $"Verify decryption: for f in config/secrets/($environment)/*.yaml; do sops --decrypt \\$f | head -1; done" } }