Adds KMS, secrets management, config encryption, and auth plugins to enable zero-trust security architecture across the provisioning platform.
506 lines
14 KiB
Plaintext
506 lines
14 KiB
Plaintext
# Configuration Encryption Module for Provisioning System
|
||
# Provides transparent encryption/decryption for configuration files using SOPS
|
||
|
||
use std log
|
||
use ../sops/lib.nu *
|
||
use ../kms/lib.nu *
|
||
use accessor.nu *
|
||
|
||
# Detect if a config file is encrypted
|
||
export def is-encrypted-config [
|
||
file_path: string
|
||
]: nothing -> bool {
|
||
if not ($file_path | path exists) {
|
||
return false
|
||
}
|
||
|
||
# Check if file has SOPS metadata
|
||
is_sops_file $file_path
|
||
}
|
||
|
||
# Load configuration file with automatic decryption
|
||
export def load-encrypted-config [
|
||
file_path: string
|
||
--debug = false
|
||
]: nothing -> record {
|
||
if not ($file_path | path exists) {
|
||
error make {
|
||
msg: $"Configuration file not found: ($file_path)"
|
||
}
|
||
}
|
||
|
||
if $debug {
|
||
print $"Loading configuration file: ($file_path)"
|
||
}
|
||
|
||
# Check if file is encrypted
|
||
if (is-encrypted-config $file_path) {
|
||
if $debug {
|
||
print $" Detected encrypted file, decrypting..."
|
||
}
|
||
|
||
# Decrypt in memory (never write to disk)
|
||
let decrypted_content = (decrypt-config-memory $file_path --debug=$debug)
|
||
|
||
# Parse based on file extension
|
||
let ext = ($file_path | path parse | get extension)
|
||
match $ext {
|
||
"yaml" | "yml" => ($decrypted_content | from yaml)
|
||
"toml" => ($decrypted_content | from toml)
|
||
"json" => ($decrypted_content | from json)
|
||
_ => {
|
||
error make {
|
||
msg: $"Unsupported encrypted config format: ($ext)"
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
if $debug {
|
||
print $" Loading unencrypted file..."
|
||
}
|
||
# Load unencrypted file normally
|
||
open $file_path
|
||
}
|
||
}
|
||
|
||
# Decrypt configuration file in memory only (never writes to disk)
|
||
export def decrypt-config-memory [
|
||
file_path: string
|
||
--debug = false
|
||
]: nothing -> string {
|
||
if not (is-encrypted-config $file_path) {
|
||
error make {
|
||
msg: $"File is not encrypted: ($file_path)"
|
||
}
|
||
}
|
||
|
||
# Use SOPS to decrypt (output goes to stdout, captured in memory)
|
||
let decrypted = (on_sops "decrypt" $file_path --quiet)
|
||
|
||
if ($decrypted | is-empty) {
|
||
error make {
|
||
msg: $"Failed to decrypt configuration file: ($file_path)"
|
||
}
|
||
}
|
||
|
||
$decrypted
|
||
}
|
||
|
||
# Encrypt a configuration file
|
||
export def encrypt-config [
|
||
source_path: string
|
||
output_path?: string
|
||
--kms: string = "age" # age, aws-kms, vault
|
||
--in-place = false
|
||
--debug = false
|
||
]: nothing -> nothing {
|
||
if not ($source_path | path exists) {
|
||
error make {
|
||
msg: $"Source file not found: ($source_path)"
|
||
}
|
||
}
|
||
|
||
# Check if already encrypted
|
||
if (is-encrypted-config $source_path) {
|
||
print $"⚠️ File is already encrypted: ($source_path)"
|
||
return
|
||
}
|
||
|
||
# Determine output path
|
||
let target = if $in_place {
|
||
$source_path
|
||
} else if ($output_path | is-not-empty) {
|
||
$output_path
|
||
} else {
|
||
$"($source_path).enc"
|
||
}
|
||
|
||
if $debug {
|
||
print $"Encrypting ($source_path) → ($target) using ($kms)"
|
||
}
|
||
|
||
# Encrypt based on KMS backend
|
||
match $kms {
|
||
"age" => {
|
||
let encrypted = (on_sops "encrypt" $source_path)
|
||
if ($encrypted | is-empty) {
|
||
error make {
|
||
msg: $"Failed to encrypt file with age: ($source_path)"
|
||
}
|
||
}
|
||
$encrypted | save --force $target
|
||
print $"✅ Encrypted successfully: ($target)"
|
||
}
|
||
"aws-kms" => {
|
||
# For AWS KMS, SOPS will use AWS KMS backend if configured
|
||
let encrypted = (on_sops "encrypt" $source_path)
|
||
if ($encrypted | is-empty) {
|
||
error make {
|
||
msg: $"Failed to encrypt file with AWS KMS: ($source_path)"
|
||
}
|
||
}
|
||
$encrypted | save --force $target
|
||
print $"✅ Encrypted successfully with AWS KMS: ($target)"
|
||
}
|
||
"vault" | "cosmian" => {
|
||
# Use KMS client for Vault or Cosmian KMS
|
||
let encrypted = (on_kms "encrypt" $source_path)
|
||
if ($encrypted | is-empty) {
|
||
error make {
|
||
msg: $"Failed to encrypt file with ($kms): ($source_path)"
|
||
}
|
||
}
|
||
$encrypted | save --force $target
|
||
print $"✅ Encrypted successfully with ($kms): ($target)"
|
||
}
|
||
_ => {
|
||
error make {
|
||
msg: $"Unsupported KMS backend: ($kms). Supported: age, aws-kms, vault, cosmian"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
# Decrypt a configuration file
|
||
export def decrypt-config [
|
||
source_path: string
|
||
output_path?: string
|
||
--in-place = false
|
||
--debug = false
|
||
]: nothing -> nothing {
|
||
if not ($source_path | path exists) {
|
||
error make {
|
||
msg: $"Source file not found: ($source_path)"
|
||
}
|
||
}
|
||
|
||
# Check if encrypted
|
||
if not (is-encrypted-config $source_path) {
|
||
print $"⚠️ File is not encrypted: ($source_path)"
|
||
return
|
||
}
|
||
|
||
# Determine output path
|
||
let target = if $in_place {
|
||
$source_path
|
||
} else if ($output_path | is-not-empty) {
|
||
$output_path
|
||
} else {
|
||
# Remove .enc extension if present
|
||
if ($source_path | str ends-with ".enc") {
|
||
$source_path | str substring 0..-5
|
||
} else {
|
||
$"($source_path).dec"
|
||
}
|
||
}
|
||
|
||
if $debug {
|
||
print $"Decrypting ($source_path) → ($target)"
|
||
}
|
||
|
||
# Decrypt using SOPS (works with any backend)
|
||
let decrypted = (on_sops "decrypt" $source_path)
|
||
if ($decrypted | is-empty) {
|
||
error make {
|
||
msg: $"Failed to decrypt file: ($source_path)"
|
||
}
|
||
}
|
||
|
||
$decrypted | save --force $target
|
||
print $"✅ Decrypted successfully: ($target)"
|
||
}
|
||
|
||
# Edit encrypted config file (decrypt, edit, re-encrypt)
|
||
export def edit-encrypted-config [
|
||
file_path: string
|
||
--editor: string = ""
|
||
--debug = false
|
||
]: nothing -> nothing {
|
||
if not ($file_path | path exists) {
|
||
error make {
|
||
msg: $"File not found: ($file_path)"
|
||
}
|
||
}
|
||
|
||
# Check if encrypted
|
||
if not (is-encrypted-config $file_path) {
|
||
print $"⚠️ File is not encrypted, opening normally..."
|
||
let editor_cmd = if ($editor | is-not-empty) {
|
||
$editor
|
||
} else {
|
||
$env.EDITOR? | default "vim"
|
||
}
|
||
^$editor_cmd $file_path
|
||
return
|
||
}
|
||
|
||
# Use SOPS editor functionality (handles decrypt -> edit -> encrypt)
|
||
print $"🔒 Opening encrypted file with SOPS editor..."
|
||
|
||
# SOPS has built-in editor support
|
||
let sops_config = (find-sops-config-path)
|
||
if ($sops_config | is-not-empty) {
|
||
^sops --config $sops_config $file_path
|
||
} else {
|
||
^sops $file_path
|
||
}
|
||
|
||
print $"✅ File saved and re-encrypted"
|
||
}
|
||
|
||
# Rotate encryption keys for a file
|
||
export def rotate-encryption-keys [
|
||
file_path: string
|
||
new_key_id: string
|
||
--debug = false
|
||
]: nothing -> nothing {
|
||
if not ($file_path | path exists) {
|
||
error make {
|
||
msg: $"File not found: ($file_path)"
|
||
}
|
||
}
|
||
|
||
if not (is-encrypted-config $file_path) {
|
||
error make {
|
||
msg: $"File is not encrypted: ($file_path)"
|
||
}
|
||
}
|
||
|
||
print $"🔄 Rotating encryption keys for ($file_path)"
|
||
print $" New key: ($new_key_id)"
|
||
|
||
# Create temporary decrypted file
|
||
let temp_file = ($file_path | str replace ".yaml" ".tmp.yaml")
|
||
|
||
try {
|
||
# Decrypt to temp
|
||
let decrypted = (on_sops "decrypt" $file_path)
|
||
$decrypted | save --force $temp_file
|
||
|
||
# Update SOPS config to use new key
|
||
# This requires updating .sops.yaml to reference new key
|
||
print $"⚠️ Manual step required: Update .sops.yaml with new key ($new_key_id)"
|
||
print $" Then run: sops updatekeys ($file_path)"
|
||
|
||
# Re-encrypt with new key
|
||
^sops updatekeys $file_path
|
||
|
||
# Remove temp file
|
||
rm --force $temp_file
|
||
|
||
print $"✅ Key rotation completed"
|
||
} catch {
|
||
# Clean up temp file on error
|
||
if ($temp_file | path exists) {
|
||
rm --force $temp_file
|
||
}
|
||
error make {
|
||
msg: $"Key rotation failed for ($file_path)"
|
||
}
|
||
}
|
||
}
|
||
|
||
# Validate encryption configuration
|
||
export def validate-encryption-config []: nothing -> record {
|
||
mut errors = []
|
||
mut warnings = []
|
||
|
||
# Check if SOPS is installed
|
||
let sops_check = (^which sops | complete)
|
||
if $sops_check.exit_code != 0 {
|
||
$errors = ($errors | append {
|
||
type: "missing_binary"
|
||
severity: "error"
|
||
message: "SOPS binary not found. Install with: brew install sops"
|
||
})
|
||
}
|
||
|
||
# Check if Age is installed (if using Age)
|
||
let use_sops = (get-provisioning-use-sops)
|
||
if ($use_sops | str contains "age") {
|
||
let age_check = (^which age | complete)
|
||
if $age_check.exit_code != 0 {
|
||
$warnings = ($warnings | append {
|
||
type: "missing_binary"
|
||
severity: "warning"
|
||
message: "Age binary not found. Install with: brew install age"
|
||
})
|
||
}
|
||
|
||
# Check for Age keys
|
||
let age_key_file = (get-sops-age-key-file)
|
||
if ($age_key_file | is-empty) {
|
||
$warnings = ($warnings | append {
|
||
type: "missing_key"
|
||
severity: "warning"
|
||
message: "Age key file not configured (PROVISIONING_KAGE)"
|
||
})
|
||
} else if not ($age_key_file | path exists) {
|
||
$errors = ($errors | append {
|
||
type: "missing_key_file"
|
||
severity: "error"
|
||
message: $"Age key file not found: ($age_key_file)"
|
||
})
|
||
}
|
||
}
|
||
|
||
# Check SOPS configuration file
|
||
let sops_config = (find-sops-config-path)
|
||
if ($sops_config | is-empty) {
|
||
$warnings = ($warnings | append {
|
||
type: "missing_config"
|
||
severity: "warning"
|
||
message: "SOPS config file (.sops.yaml) not found in project"
|
||
})
|
||
}
|
||
|
||
# Check KMS configuration if enabled
|
||
let kms_enabled = (get-kms-enabled)
|
||
if $kms_enabled {
|
||
let kms_server = (get-kms-server)
|
||
if ($kms_server | is-empty) {
|
||
$errors = ($errors | append {
|
||
type: "missing_kms_config"
|
||
severity: "error"
|
||
message: "KMS enabled but server URL not configured"
|
||
})
|
||
}
|
||
}
|
||
|
||
{
|
||
valid: (($errors | length) == 0)
|
||
errors: $errors
|
||
warnings: $warnings
|
||
summary: {
|
||
sops_installed: ($sops_check.exit_code == 0)
|
||
age_backend: ($use_sops | str contains "age")
|
||
kms_enabled: $kms_enabled
|
||
total_errors: ($errors | length)
|
||
total_warnings: ($warnings | length)
|
||
}
|
||
}
|
||
}
|
||
|
||
# Find SOPS configuration file
|
||
def find-sops-config-path []: nothing -> string {
|
||
# Check common locations
|
||
let locations = [
|
||
".sops.yaml"
|
||
".sops.yml"
|
||
($env.PWD | path join ".sops.yaml")
|
||
($env.HOME | path join ".config" | path join "provisioning" | path join "sops.yaml")
|
||
(get-sops-config-path)
|
||
]
|
||
|
||
for loc in $locations {
|
||
if ($loc | path exists) {
|
||
return $loc
|
||
}
|
||
}
|
||
|
||
""
|
||
}
|
||
|
||
# Check if config file contains sensitive data (heuristic)
|
||
export def contains-sensitive-data [
|
||
file_path: string
|
||
]: nothing -> bool {
|
||
if not ($file_path | path exists) {
|
||
return false
|
||
}
|
||
|
||
let content = (open $file_path --raw)
|
||
|
||
# Patterns that indicate sensitive data
|
||
let sensitive_patterns = [
|
||
"password" "secret" "api_key" "token" "private_key"
|
||
"credential" "auth" "access_key" "secret_key"
|
||
]
|
||
|
||
for pattern in $sensitive_patterns {
|
||
if ($content | str contains $pattern) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
# Scan directory for unencrypted sensitive configs
|
||
export def scan-unencrypted-configs [
|
||
directory: string
|
||
--recursive = true
|
||
]: nothing -> table {
|
||
mut results = []
|
||
|
||
let files = if $recursive {
|
||
glob $"($directory)/**/*.{yaml,yml,toml,json}"
|
||
} else {
|
||
glob $"($directory)/*.{yaml,yml,toml,json}"
|
||
}
|
||
|
||
for file in $files {
|
||
if (contains-sensitive-data $file) and not (is-encrypted-config $file) {
|
||
$results = ($results | append {
|
||
file: $file
|
||
encrypted: false
|
||
has_sensitive_data: true
|
||
recommendation: "Should be encrypted"
|
||
})
|
||
}
|
||
}
|
||
|
||
$results
|
||
}
|
||
|
||
# Encrypt all sensitive configs in directory
|
||
export def encrypt-sensitive-configs [
|
||
directory: string
|
||
--kms: string = "age"
|
||
--dry-run = false
|
||
--recursive = true
|
||
]: nothing -> nothing {
|
||
print $"🔍 Scanning for unencrypted sensitive configs in ($directory)"
|
||
|
||
let unencrypted = (scan-unencrypted-configs $directory --recursive=$recursive)
|
||
|
||
if ($unencrypted | is-empty) {
|
||
print $"✅ No unencrypted sensitive configs found"
|
||
return
|
||
}
|
||
|
||
print $"\n📋 Found ($unencrypted | length) unencrypted sensitive configs:"
|
||
print $unencrypted
|
||
|
||
if $dry_run {
|
||
print $"\n⚠️ Dry run mode - no files will be encrypted"
|
||
return
|
||
}
|
||
|
||
print $"\n🔒 Encrypting files with ($kms)..."
|
||
|
||
for file in ($unencrypted | get file) {
|
||
print $" Encrypting: ($file)"
|
||
encrypt-config $file --kms $kms --in-place
|
||
}
|
||
|
||
print $"\n✅ Encryption completed for all sensitive configs"
|
||
}
|
||
|
||
# Export key functions for CLI integration
|
||
export def main [] {
|
||
print "Configuration Encryption Module"
|
||
print "================================"
|
||
print ""
|
||
print "Available commands:"
|
||
print " - is-encrypted-config <file> Check if config is encrypted"
|
||
print " - load-encrypted-config <file> Load and decrypt config"
|
||
print " - encrypt-config <file> Encrypt config file"
|
||
print " - decrypt-config <file> Decrypt config file"
|
||
print " - edit-encrypted-config <file> Edit encrypted config"
|
||
print " - rotate-encryption-keys <file> Rotate encryption keys"
|
||
print " - validate-encryption-config Validate encryption setup"
|
||
print " - scan-unencrypted-configs <dir> Find unencrypted sensitive configs"
|
||
print " - encrypt-sensitive-configs <dir> Encrypt all sensitive configs"
|
||
}
|