Files
dtic-DIIAA/adn/tools/candados/candados.rb
T

491 lines
18 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# frozen_string_literal: true
# candados — Gestión unificada de secretos y autorización SSH
# ==============================================================================
# 🤖 INSTRUCCIONES PARA LA IA (AGENTES EXTERNOS):
# Esta herramienta permite obtener credenciales seguras sin exponerlas.
#
# COMANDO PRIMARIO (RECOMENDADO):
# ruby candados.rb run <clave> '<comando con $USR y $PASS>'
# → Inyecta USR y PASS en el entorno del subproceso. Nada se imprime.
#
# COMANDO SECUNDARIO (USO INTERACTIVO):
# eval $(ruby candados.rb load <clave>)
# → Carga USR y PASS en el shell actual via archivo temporal seguro (600).
#
# ⚠ NUNCA usar `get` directamente en run_command: el secreto se filtra en logs.
# ==============================================================================
# Uso: ruby adn/tools/seguridad/candados.rb <comando> [args]
#
# Comandos principales:
# run <clave> <cmd> Ejecutar comando con USR y PASS inyectados (PRIMARIO)
# sudo <cmd> Ejecutar comando con sudo desde la bóveda
# load <clave> Cargar USR/PASS en shell (eval, archivo temporal seguro)
#
# Comandos de gestión:
# authorize Abrir candado (sesión temporal 5 min)
# set <clave> Guardar secreto (input seguro, sin eco)
# get <clave> Obtener secreto (⚠ imprime en stdout)
# rm <clave> Eliminar secreto
# list Listar claves disponibles
require 'openssl'
require 'base64'
require 'json'
require 'io/console'
require 'fileutils'
# ─── Rutas ───────────────────────────────────────────────────────────
TOOL_DIR = File.expand_path(__dir__)
MASTER_KEY = File.join(TOOL_DIR, '.master.key')
OLD_KEY = File.expand_path('../../.master.key', TOOL_DIR)
BOVEDA = File.join(TOOL_DIR, '.boveda.json')
SESION = File.join(TOOL_DIR, '.session')
ACCESS_LOG = File.join(TOOL_DIR, 'access.log')
# ─── Transición de Seguridad ─────────────────────────────────────────
if File.exist?(OLD_KEY) && !File.exist?(MASTER_KEY)
FileUtils.mv(OLD_KEY, MASTER_KEY)
FileUtils.chmod(0600, MASTER_KEY)
end
# ─── Colores ─────────────────────────────────────────────────────────
module C
RESET = "\e[0m"
BOLD = "\e[1m"
GREEN = "\e[32m"
CYAN = "\e[36m"
YELLOW = "\e[33m"
RED = "\e[31m"
DIM = "\e[2m"
end
# ─── Motor Criptográfico (AES-256-GCM) ──────────────────────────────
class Cripto
ALGORITHM = 'aes-256-gcm'
def initialize(key_content)
@key = Digest::SHA256.digest(key_content.strip)
end
def encrypt(plaintext)
cipher = OpenSSL::Cipher.new(ALGORITHM).tap do |c|
c.encrypt
c.key = @key
end
iv = cipher.random_iv
ciphertext = cipher.update(plaintext.to_s) + cipher.final
payload = { iv: b64(iv), tag: b64(cipher.auth_tag), data: b64(ciphertext) }
Base64.strict_encode64(payload.to_json)
end
def decrypt(token)
p = JSON.parse(Base64.strict_decode64(token), symbolize_names: true)
cipher = OpenSSL::Cipher.new(ALGORITHM).tap do |c|
c.decrypt
c.key = @key
c.iv = db64(p[:iv])
c.auth_tag = db64(p[:tag])
end
cipher.update(db64(p[:data])) + cipher.final
rescue => e
abort "#{C::RED}✗ Error al descifrar: #{e.message}#{C::RESET}"
end
private
def b64(d) = Base64.strict_encode64(d)
def db64(d) = Base64.strict_decode64(d)
end
# ─── Bóveda ──────────────────────────────────────────────────────────
class Boveda
def initialize(path)
@path = path
@data = File.exist?(path) ? JSON.parse(File.read(path)) : {}
end
def keys = @data.keys
def get(k) = @data[k]
def set(k, v) = (@data[k] = v) && save!
def rm(k) = @data.delete(k) && save!
def empty? = @data.empty?
private
def save!
File.write(@path, JSON.pretty_generate(@data) + "\n")
FileUtils.chmod(0600, @path)
end
end
# ─── Master Key ──────────────────────────────────────────────────────
def cargar_master_key
unless File.exist?(MASTER_KEY)
require 'securerandom'
File.write(MASTER_KEY, SecureRandom.hex(32))
FileUtils.chmod(0600, MASTER_KEY)
$stderr.puts "#{C::YELLOW}⚠ Nueva clave maestra creada en local: #{MASTER_KEY}#{C::RESET}"
end
File.read(MASTER_KEY)
end
# ─── Auditoría ───────────────────────────────────────────────────────
def registrar_acceso(accion, detalle = '')
usuario = ENV['USER'] || ENV['USERNAME'] || 'unknown'
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
log_entry = "[#{timestamp}] - user:#{usuario} - action:#{accion} - #{detalle}\n"
File.open(ACCESS_LOG, 'a') do |f|
f.write(log_entry)
end
FileUtils.chmod(0600, ACCESS_LOG) if File.exist?(ACCESS_LOG)
end
# ─── Gestión de Sesión (MFA) ─────────────────────────────────────────
def autorizar!
File.write(SESION, Time.now.to_i.to_s)
FileUtils.chmod(0600, SESION)
registrar_acceso('AUTHORIZE', 'Candado abierto (Sesión iniciada)')
$stderr.puts "#{C::GREEN}✓ Autorización concedida (Válida por 5 minutos).#{C::RESET}"
end
def cerrar_sesion!
if File.exist?(SESION)
FileUtils.rm_f(SESION)
registrar_acceso('LOCK', 'Candado cerrado (Sesión finalizada)')
$stderr.puts "#{C::YELLOW}🔒 Sesión cerrada. Candado puesto.#{C::RESET}"
end
end
def verificar_sesion!
unless File.exist?(SESION)
registrar_acceso('DENIED', 'Intento de acceso sin autorización')
abort "#{C::RED}✗ Error: El candado está puesto. Ejecutá 'ruby #{File.basename($0)} authorize' para abrirlo.#{C::RESET}"
end
inicio = File.read(SESION).to_i
if Time.now.to_i - inicio > 300 # 5 minutos
cerrar_sesion!
registrar_acceso('EXPIRED', 'Sesión expirada automáticamente')
abort "#{C::RED}✗ Sesión expirada. Por seguridad, volvé a autorizar.#{C::RESET}"
end
end
# ─── Comandos ────────────────────────────────────────────────────────
def cmd_abrir
puts "#{C::CYAN}🔐 candados: Autorización SSH#{C::RESET}"
puts "#{C::DIM}Limpiando identidades previas...#{C::RESET}"
system('ssh-add -D > /dev/null 2>&1')
puts "#{C::YELLOW}🔑 Ingresá tu passphrase:#{C::RESET}"
system('ssh-add')
system('ssh-add -l > /dev/null 2>&1')
unless $?.success?
abort "#{C::RED}✗ Sin identidades SSH cargadas. Abortando.#{C::RESET}"
end
puts "#{C::GREEN}🔓 SSH autorizado. Las identidades permanecen activas mientras dure la sesión del agente.#{C::RESET}"
end
def cmd_encrypt(cripto, texto)
abort "#{C::RED}✗ Falta texto a cifrar.#{C::RESET}" if texto.nil? || texto.empty?
puts cripto.encrypt(texto)
end
def cmd_decrypt(cripto, token)
abort "#{C::RED}✗ Falta payload a descifrar.#{C::RESET}" if token.nil? || token.empty?
puts cripto.decrypt(token)
end
def cmd_get(cripto, boveda, clave)
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
verificar_sesion!
token = boveda.get(clave)
abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" if token.nil?
registrar_acceso('GET', "Clave: #{clave}")
puts cripto.decrypt(token)
end
def cmd_run(cripto, boveda, clave, *args)
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
abort "#{C::RED}✗ Falta comando a ejecutar.#{C::RESET}" if args.empty? || args.all?(&:empty?)
# Retrocompatibilidad: candados run <clave> <VAR> <comando>
env_var_legacy = nil
if args.size >= 2 && args[0].match?(/^[A-Z_]+$/)
env_var_legacy = args.shift
end
comando = args
verificar_sesion!
token_pass = boveda.get(clave)
abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" if token_pass.nil?
pass_value = cripto.decrypt(token_pass)
user_value = ''
# Buscar credenciales par (_user/_pass)
if clave.end_with?('_pass')
prefijo = clave.sub(/_pass$/, '')
token_user = boveda.get("#{prefijo}_user")
user_value = cripto.decrypt(token_user) if token_user
elsif clave.include?(':')
user_value = clave.split(':').last
else
token_user = boveda.get("#{clave}_user")
user_value = cripto.decrypt(token_user) if token_user
end
registrar_acceso('RUN', "Clave: #{clave}, Comando: #{comando.join(' ')}")
# Inyectar variables globalmente al entorno del comando
ENV['USR'] = user_value
ENV['PASS'] = pass_value
ENV[env_var_legacy] = pass_value if env_var_legacy
begin
if comando.length == 1
system(comando[0])
else
system(*comando)
end
exit $?.exitstatus || 0
ensure
ENV.delete('USR')
ENV.delete('PASS')
ENV.delete(env_var_legacy) if env_var_legacy
end
end
def cmd_set(cripto, boveda, clave, valor = nil)
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
if valor.nil? || valor.empty?
$stderr.print "#{C::YELLOW}🔑 Valor para '#{clave}': #{C::RESET}"
valor = $stdin.noecho(&:gets)&.chomp
$stderr.puts
end
abort "#{C::RED}✗ Valor vacío.#{C::RESET}" if valor.nil? || valor.empty?
boveda.set(clave, cripto.encrypt(valor))
$stderr.puts "#{C::GREEN}✓ Clave '#{clave}' guardada en la bóveda.#{C::RESET}"
end
def cmd_rm(boveda, clave)
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" unless boveda.get(clave)
boveda.rm(clave)
$stderr.puts "#{C::GREEN}✓ Clave '#{clave}' eliminada.#{C::RESET}"
end
def cmd_list(boveda)
if boveda.empty?
$stderr.puts "#{C::DIM}Bóveda vacía.#{C::RESET}"
return
end
$stderr.puts "#{C::CYAN}🔐 Claves en la bóveda:#{C::RESET}"
boveda.keys.each { |k| $stderr.puts " #{C::GREEN}#{C::RESET} #{k}" }
end
def cmd_load(cripto, boveda, clave)
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
verificar_sesion!
# Buscar credenciales por convención: <clave> contiene user y <clave> para pass,
# o <prefijo>_user y <prefijo>_pass si existen como par.
token_pass = boveda.get(clave)
abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" if token_pass.nil?
pass_value = cripto.decrypt(token_pass)
user_value = ''
# Intentar encontrar el par _user/_pass automáticamente
if clave.end_with?('_pass')
prefijo = clave.sub(/_pass$/, '')
token_user = boveda.get("#{prefijo}_user")
user_value = cripto.decrypt(token_user) if token_user
elsif clave.include?(':')
# Formato "servidor:usuario" → USR es la parte después de :
user_value = clave.split(':').last
else
# Buscar si existe una clave hermana _user
token_user = boveda.get("#{clave}_user")
if token_user
user_value = cripto.decrypt(token_user)
end
end
registrar_acceso('LOAD', "Clave: #{clave}")
# Seguridad: NUNCA imprimir secretos a stdout/stderr.
# Escribir exports en un archivo temporal con permisos estrictos (600).
# A stdout solo emitimos el comando `source` + autoborrado.
require 'securerandom'
tmpfile_path = "/tmp/.candados_env_#{SecureRandom.hex(8)}"
safe_user = user_value.gsub("'", "'\\''")
safe_pass = pass_value.gsub("'", "'\\''")
File.write(tmpfile_path, "export USR='#{safe_user}'\nexport PASS='#{safe_pass}'\n")
File.chmod(0600, tmpfile_path)
# Solo el comando source + autoborrado va a stdout (evaluable por el shell)
puts "source '#{tmpfile_path}' && rm -f '#{tmpfile_path}'"
$stderr.puts "#{C::GREEN}✓ Credenciales cargadas en variables USR y PASS (archivo temporal seguro).#{C::RESET}"
end
# ─── Comando sudo (primitiva reutilizable) ─────────────────────
# Resolución inteligente de la clave sudo:
# 1. <hostname>:<usuario>:sudo (convención específica)
# 2. sudo (fallback genérico)
def cmd_sudo(cripto, boveda, comando_args)
abort "#{C::RED}✗ Falta comando a ejecutar con sudo.#{C::RESET}" if comando_args.nil? || comando_args.empty?
verificar_sesion!
# Resolver la clave sudo con búsqueda inteligente
hostname = `hostname -s 2>/dev/null`.strip
usuario = ENV['USER'] || ENV['USERNAME'] || 'root'
# Generar variantes de hostname para búsqueda flexible
# Ej: srvNS8 → ["srvNS8", "srvns8", "ns8", "NS8"]
variantes = [hostname]
variantes << hostname.downcase
variantes << hostname.sub(/^srv[-_]?/i, '') # sin prefijo srv/srv-/srv_
variantes << hostname.sub(/^srv[-_]?/i, '').downcase
variantes.uniq!
# Buscar la primera clave que exista en la bóveda
token = nil
clave_usada = nil
variantes.each do |h|
clave = "#{h}:#{usuario}:sudo"
if boveda.get(clave)
token = boveda.get(clave)
clave_usada = clave
break
end
end
# Fallback genérico
unless token
if boveda.get('sudo')
token = boveda.get('sudo')
clave_usada = 'sudo'
end
end
if token.nil?
buscadas = variantes.map { |h| "#{h}:#{usuario}:sudo" }.join("', '")
$stderr.puts "#{C::RED}✗ No se encontró clave sudo en la bóveda.#{C::RESET}"
$stderr.puts " Buscadas: '#{buscadas}', 'sudo'"
$stderr.puts " Registrala con: ruby candados.rb set #{variantes.first}:#{usuario}:sudo"
abort
end
sudo_pass = cripto.decrypt(token)
registrar_acceso('SUDO', "Clave: #{clave_usada}, Cmd: #{comando_args.join(' ')}")
# Ejecutar con sudo -S (hereda stdout/stderr, pipe solo en stdin)
cmd_full = comando_args.join(' ')
IO.popen("sudo -S #{cmd_full}", 'w') do |pipe|
pipe.puts sudo_pass
end
exit $?.exitstatus || 0
end
def cmd_help
puts <<~HELP
#{C::CYAN}🔐 candados — Gestión segura de secretos#{C::RESET}
#{C::GREEN}Uso:#{C::RESET} ruby adn/tools/seguridad/candados.rb <comando> [args]
#{C::YELLOW}━━━ Comandos principales ━━━#{C::RESET}
#{C::BOLD}run <clave> <cmd>#{C::RESET} #{C::GREEN}★ PRIMARIO#{C::RESET} Ejecutar comando con USR y PASS inyectados
#{C::BOLD}sudo <comando>#{C::RESET} Ejecutar con privilegios (resuelve clave automáticamente)
#{C::BOLD}load <clave>#{C::RESET} Cargar USR/PASS en shell actual (usar con eval)
#{C::YELLOW}━━━ Gestión de bóveda ━━━#{C::RESET}
authorize Abrir candado (autorización temporal 5 min)
cerrar Cerrar candado manualmente
set <clave> Guardar secreto (input seguro, sin eco)
get <clave> Obtener secreto ( imprime a stdout)
rm <clave> Eliminar secreto
list Listar claves disponibles
#{C::YELLOW}━━━ Utilidades ━━━#{C::RESET}
abrir Autorizar SSH (cargar passphrase)
encrypt <texto> Cifrar texto plano token
decrypt <token> Descifrar token texto plano
#{C::YELLOW}Resolución automática de claves:#{C::RESET}
#{C::DIM}• run/load: Detecta pares USR/PASS por convención:#{C::RESET}
srv-dasu:rmonla USR=rmonla, PASS=(valor cifrado)
tailscale_dasuten busca _user/_pass automáticamente
#{C::DIM}• sudo: Resuelve la clave de sudo automáticamente:#{C::RESET}
<hostname>:<usuario>:sudo (ej: ns8:rmonla:sudo)
#{C::YELLOW}Ejemplos:#{C::RESET}
#{C::DIM}# 1. Autorizar (dura 5 min)#{C::RESET}
ruby candados.rb authorize
#{C::GREEN}# 2. Ejecutar comando con credenciales (RECOMENDADO)#{C::RESET}
ruby candados.rb run srv-dasu:rmonla 'sshpass -p $PASS ssh $USR@host'
ruby candados.rb run admindasu SSHPASS 'sshpass -e ssh root@host'
#{C::DIM}# 3. Ejecutar con sudo#{C::RESET}
ruby candados.rb sudo tailscale set --operator=$USER
#{C::DIM}# 4. Cargar en shell interactivo (secundario)#{C::RESET}
eval $(ruby candados.rb load srv-dasu:rmonla)
#{C::RED}⚠ Seguridad:#{C::RESET}
#{C::DIM}• run/sudo: Las credenciales NUNCA se imprimen en pantalla ni logs.#{C::RESET}
#{C::DIM}• load: Usa archivo temporal (600) con autoborrado, nada visible.#{C::RESET}
#{C::DIM}• get: ⚠ Imprime el valor a stdout. Usar solo si es necesario.#{C::RESET}
HELP
end
# ─── Main ────────────────────────────────────────────────────────────
if __FILE__ == $0
comando = ARGV[0]
arg = ARGV[1]
case comando
when 'authorize'
autorizar!
when 'cerrar'
cerrar_sesion!
when 'sudo'
cripto = Cripto.new(cargar_master_key)
boveda = Boveda.new(BOVEDA)
cmd_sudo(cripto, boveda, ARGV[1..])
when 'encrypt', 'decrypt', 'get', 'load', 'run', 'set', 'rm', 'list'
cripto = Cripto.new(cargar_master_key)
boveda = Boveda.new(BOVEDA)
case comando
when 'encrypt' then cmd_encrypt(cripto, arg)
when 'decrypt' then cmd_decrypt(cripto, arg)
when 'get' then cmd_get(cripto, boveda, arg)
when 'load' then cmd_load(cripto, boveda, arg)
when 'run' then cmd_run(cripto, boveda, arg, *ARGV[2..])
when 'set' then cmd_set(cripto, boveda, arg, ARGV[2])
when 'rm' then cmd_rm(boveda, arg)
when 'list' then cmd_list(boveda)
end
when 'help', 'ayuda', '-h', '--help', nil
cmd_help
else
$stderr.puts "#{C::RED}✗ Comando desconocido: '#{comando}'#{C::RESET}"
cmd_help
exit 1
end
end