163 lines
4.2 KiB
Text
163 lines
4.2 KiB
Text
|
|
#!/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 <glob>]
|
||
|
|
|
||
|
|
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"
|
||
|
|
}
|
||
|
|
}
|