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

664 lines
28 KiB
Ruby
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 30 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__)
PROJECT_ROOT = File.expand_path('../../..', TOOL_DIR) # Desde adn/tools/candados -> adn/tools -> adn -> root
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')
TMP_DIR = File.join(PROJECT_ROOT, 'tmp') # Directorio para temporales (principio 11 - 05_ia.md)
# ─── 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 30 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 > 1800 # 30 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(cripto, boveda)
verificar_sesion!
token = boveda.get('rsa')
abort "#{C::RED}✗ Clave 'rsa' no encontrada en la bóveda. Guardala con: ruby candados.rb set rsa#{C::RESET}" if token.nil?
passphrase = cripto.decrypt(token)
registrar_acceso('ABRIR', 'SSH key load via ssh-add')
$stderr.puts "#{C::CYAN}🔐 candados: Cargando clave SSH...#{C::RESET}"
system('ssh-add -D > /dev/null 2>&1')
# Crear script temporal para SSH_ASKPASS (no interactivo)
require 'securerandom'
FileUtils.mkdir_p(TMP_DIR) unless File.exist?(TMP_DIR)
askpass_path = File.join(TMP_DIR, ".ssh_askpass_#{SecureRandom.hex(4)}")
safe_pass = passphrase.gsub("'", "'\\''")
File.write(askpass_path, "#!/bin/sh\necho '#{safe_pass}'\n")
File.chmod(0700, askpass_path)
# Cargar clave via SSH_ASKPASS
ENV['SSH_ASKPASS'] = askpass_path
ENV['SSH_ASKPASS_REQUIRE'] = 'force'
ENV['DISPLAY'] = ENV['DISPLAY'] || ':0'
system('ssh-add < /dev/null')
# Limpiar
FileUtils.rm_f(askpass_path)
ENV.delete('SSH_ASKPASS')
ENV.delete('SSH_ASKPASS_REQUIRE')
if system('ssh-add -l > /dev/null 2>&1')
$stderr.puts "#{C::GREEN}🔓 SSH autorizado. Identidades cargadas en el agente.#{C::RESET}"
else
abort "#{C::RED}✗ Sin identidades SSH cargadas. Verificá la passphrase en 'rsa'.#{C::RESET}"
end
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?(':')
# Formato: <nodo>:<usuario>[:<tipo>]
# Ej: "srv-ns8:rmonla" → usuario="rmonla"
# "srv-ns8:rmonla:sudo" → usuario="rmonla" (no "sudo")
partes = clave.split(':')
if partes.size >= 2
# El usuario es siempre la segunda parte (índice 1)
user_value = partes[1]
end
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.
# NOTA: Usamos tmp/ del proyecto (no /tmp/) para cumplir principio 11 de 05_ia.md
require 'securerandom'
FileUtils.mkdir_p(TMP_DIR) unless File.exist?(TMP_DIR)
tmpfile_path = File.join(TMP_DIR, ".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 ssh (ejecutar comando remoto con password de bóveda) ─────
# Uso: candados ssh <clave> <usuario@host> '<comando>'
# - clave: <nodo>:<usuario> para SSH (busca automáticamente <nodo>:<usuario>:sudo para sudo)
# - Si existe clave :sudo separada, la usa para comandos con sudo
def cmd_ssh(cripto, boveda, clave, usuario_host, *cmd_args)
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
abort "#{C::RED}✗ Falta usuario@host.#{C::RESET}" if usuario_host.nil? || usuario_host.empty?
abort "#{C::RED}✗ Falta comando a ejecutar.#{C::RESET}" if cmd_args.nil? || cmd_args.empty?
verificar_sesion!
# Obtener password SSH
token_ssh = boveda.get(clave)
abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" if token_ssh.nil?
ssh_pass = cripto.decrypt(token_ssh)
safe_ssh_pass = ssh_pass.gsub("'", "'\\''")
# Buscar automáticamente clave :sudo si existe
comando = cmd_args.join(' ')
sudo_pass = ssh_pass
# Intentar buscar clave :sudo basada en la clave SSH
if clave.include?(':') && !clave.end_with?(':sudo')
# Construir clave sudo: <nodo>:<usuario>:sudo
partes = clave.split(':')
if partes.size >= 2
clave_sudo = "#{partes[0]}:#{partes[1]}:sudo"
token_sudo = boveda.get(clave_sudo)
if token_sudo
sudo_pass = cripto.decrypt(token_sudo)
$stderr.puts "#{C::DIM} Usando clave sudo: #{clave_sudo}#{C::RESET}"
end
end
end
safe_sudo_pass = sudo_pass.gsub("'", "'\\''")
registrar_acceso('SSH', "Clave: #{clave}, Host: #{usuario_host}, Cmd: #{comando}")
# Escapar comando para bash remoto: reemplazar ' con '\''
comando_escaped = comando.gsub("'", "'\\''")
# Crear script temporal con el comando y password para sudo
require 'securerandom'
require 'fileutils'
FileUtils.mkdir_p(TMP_DIR) unless File.exist?(TMP_DIR)
script_path = File.join(TMP_DIR, ".ssh_sudo_#{SecureRandom.hex(4)}")
File.write(script_path, "#!/bin/bash\nexport DEBIAN_FRONTEND=noninteractive\necho '#{safe_sudo_pass}' | sudo -S -p '' bash -c '#{comando_escaped}'\n")
File.chmod(0700, script_path)
# Ejecutar: sshpass para SSH, luego script remoto que hace sudo
result = system("sshpass -p '#{safe_ssh_pass}' ssh -o StrictHostKeyChecking=no -tt #{usuario_host} 'bash -s' < '#{script_path}'")
FileUtils.rm_f(script_path)
exit result ? 0 : 1
end
# ─── Ayuda mejorada para cmd_help_resumida ─────────────────────────────
# ─── 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_resumida
puts <<~HELP
#{C::CYAN}╔═══════════════════════════════════════════════════════════════════╗#{C::RESET}
#{C::CYAN}║#{C::RESET} 🔐 #{C::BOLD}candados — Acceso Seguro a Contraseñas (Sin Exponer)#{C::RESET} #{C::CYAN}║#{C::RESET}
#{C::CYAN}╚═══════════════════════════════════════════════════════════════════╝#{C::RESET}
#{C::RED}⚠️ REGLA DE ORO: NUNCA MOSTRAR CONTRASEÑAS#{C::RESET}
#{C::GREEN}┌─────────────────────────────────────────────────────────────────┐#{C::RESET}
#{C::GREEN}│#{C::RESET} #{C::BOLD}MÉTODOS PARA CARGAR CONTRASEÑAS EN VARIABLES#{C::RESET} #{C::GREEN}│#{C::RESET}
#{C::GREEN}└─────────────────────────────────────────────────────────────────┘#{C::RESET}
#{C::BOLD}★ run <clave> 'comando'#{C::RESET} → Ejecuta con $USR y $PASS inyectadas (PRIMARIO)
#{C::BOLD}★ load <clave>#{C::RESET} → Carga $USR y $PASS en shell (usar con eval)
#{C::DIM}Ejemplo: ruby candados.rb run srv-ns8:rmonla 'sshpass -p $PASS ssh $USR@host'#{C::RESET}
#{C::YELLOW}━━━ Comandos ━━━#{C::RESET}
#{C::BOLD}run#{C::RESET} <clave> <cmd> #{C::DIM}Ejecutar con credenciales inyectadas#{C::RESET}
#{C::BOLD}load#{C::RESET} <clave> #{C::DIM}Cargar en shell actual (eval)#{C::RESET}
#{C::BOLD}sudo#{C::RESET} <comando> #{C::DIM}Ejecutar con privilegios#{C::RESET}
#{C::BOLD}ssh#{C::RESET} <clave> <u@h> <cmd> #{C::DIM}SSH remoto con password de bóveda#{C::RESET}
#{C::BOLD}authorize#{C::RESET} #{C::DIM}Abrir candado (30 min)#{C::RESET}
#{C::BOLD}list#{C::RESET} #{C::DIM}Listar claves disponibles#{C::RESET}
#{C::BOLD}set/rm#{C::RESET} <clave> #{C::DIM}Guardar/Eliminar secreto#{C::RESET}
#{C::DIM}Usá#{C::RESET} ruby candados.rb --help #{C::DIM}para ayuda completa y ejemplos detallados.#{C::RESET}
HELP
end
def cmd_help_completo
puts <<~HELP
#{C::CYAN}╔═══════════════════════════════════════════════════════════════════╗#{C::RESET}
#{C::CYAN}║#{C::RESET} 🔐 #{C::BOLD}candados — Acceso Seguro a Contraseñas (Sin Exponer)#{C::RESET} #{C::CYAN}║#{C::RESET}
#{C::CYAN}╚═══════════════════════════════════════════════════════════════════╝#{C::RESET}
#{C::RED}⚠️ REGLA DE ORO: NUNCA MOSTRAR CONTRASEÑAS#{C::RESET}
Esta herramienta está diseñada para que las contraseñas #{C::BOLD}NUNCA#{C::RESET} se muestren en pantalla
ni queden registradas en logs, históricos de shell o archivos temporales visibles.
#{C::GREEN}┌─────────────────────────────────────────────────────────────────┐#{C::RESET}
#{C::GREEN}│#{C::RESET} #{C::BOLD}MÉTODOS PARA CARGAR CONTRASEÑAS EN VARIABLES#{C::RESET} #{C::GREEN}│#{C::RESET}
#{C::GREEN}└─────────────────────────────────────────────────────────────────┘#{C::RESET}
#{C::BOLD}★ MÉTODO 1: run (PRIMARIO - Recomendado para comandos)#{C::RESET}
Ejecuta un comando con USR y PASS inyectadas en su entorno.
La contraseña #{C::BOLD}NUNCA#{C::RESET} se imprime ni queda en logs.
#{C::DIM}ruby candados.rb run <clave> 'comando que usa $USR y $PASS'#{C::RESET}
#{C::CYAN}Ejemplos:#{C::RESET}
#{C::GREEN}✓#{C::RESET} ruby candados.rb run srv-dasu:rmonla 'sshpass -p $PASS ssh $USR@host'
#{C::GREEN}✓#{C::RESET} ruby candados.rb run srv-ns8:root 'ssh $USR@$HOSTNAME "comando"'
#{C::BOLD}★ MÉTODO 2: load (Para shell interactivo)#{C::RESET}
Carga USR y PASS en variables de entorno del shell actual.
Usa un archivo temporal (600) que se auto-elimina tras el source.
#{C::DIM}eval $(ruby candados.rb load <clave>)#{C::RESET}
#{C::CYAN}Ejemplo:#{C::RESET}
#{C::GREEN}✓#{C::RESET} eval $(ruby candados.rb load srv-dasu:rmonla)
#{C::DIM} → Carga $USR y $PASS en tu shell actual#{C::RESET}
#{C::GREEN}✓#{C::RESET} sshpass -p $PASS ssh $USR@servidor
#{C::YELLOW}━━━ Otros Comandos ━━━#{C::RESET}
#{C::BOLD}sudo <comando>#{C::RESET} Ejecutar con sudo (resuelve contraseña automáticamente)
#{C::BOLD}authorize#{C::RESET} Abrir candado (sesión de 30 min, requerida para operar)
#{C::BOLD}cerrar#{C::RESET} Cerrar sesión manualmente antes de los 30 min
#{C::BOLD}abrir#{C::RESET} Cargar clave SSH en ssh-add (usa passphrase de bóveda)
#{C::YELLOW}━━━ Gestión de Bóveda ━━━#{C::RESET}
#{C::BOLD}set <clave>#{C::RESET} Guardar secreto (input seguro sin eco)
#{C::BOLD}list#{C::RESET} Listar claves disponibles (no muestra valores)
#{C::BOLD}rm <clave>#{C::RESET} Eliminar una clave de la bóveda
#{C::YELLOW}━━━ Utilidades ━━━#{C::RESET}
#{C::BOLD}encrypt <texto>#{C::RESET} Cifrar texto plano → token (para bóveda)
#{C::BOLD}decrypt <token>#{C::RESET} Descifrar token → texto plano
#{C::YELLOW}━━━ Resolución Automática de Claves ━━━#{C::RESET}
#{C::DIM}• run/load con formato `servidor:usuario`:#{C::RESET}
srv-dasu:rmonla USR=rmonla, PASS=(descifrado de bóveda)
#{C::DIM}• run/load con formato `prefijo_pass`:#{C::RESET}
tailscale_dasuten_pass busca automáticamente tailscale_dasuten_user
#{C::DIM}• sudo resuelve la clave automáticamente:#{C::RESET}
Busca: <hostname>:<usuario>:sudo fallback: `sudo`
#{C::YELLOW}━━━ Flujo de Trabajo Típico ━━━#{C::RESET}
#{C::DIM}1. Autorizar (una vez cada 30 min):#{C::RESET}
ruby candados.rb authorize
#{C::DIM}2. Ejecutar comandos (Método recomendado):#{C::RESET}
ruby candados.rb run srv-ns8:rmonla 'sshpass -p $PASS ssh $USR@host "comando"'
#{C::DIM}3. O cargar en shell interactivo:#{C::RESET}
eval $(ruby candados.rb load srv-ns8:rmonla)
sshpass -p $PASS ssh $USR@host
#{C::RED}⚠️ ADVERTENCIAS DE SEGURIDAD:#{C::RESET}
#{C::BOLD}• NUNCA usar `get`#{C::RESET} en automatizaciones → imprime la contraseña a stdout
#{C::BOLD}• `run` y `sudo`#{C::RESET} → las credenciales no dejan rastro en logs
#{C::BOLD}• `load`#{C::RESET} → archivo temporal con permisos 600, auto-borrado
#{C::BOLD}• Siempre ejecutar `authorize`#{C::RESET} antes de operar (sesión de 30 min)
#{C::DIM}Documentación completa: adn/tools/candados/README.md#{C::RESET}
HELP
end
alias cmd_help cmd_help_completo
# ─── Recordatorio de Seguridad (solo en ayuda) ──────────────────
def mostrar_recordatorio_seguridad
puts <<~RECORDATORIO
#{C::CYAN}╔════════════════════════════════════════════════════════════════╗#{C::RESET}
#{C::CYAN}║#{C::RESET} 🔐 #{C::BOLD}REGLA DE ORO: NUNCA MOSTRAR CONTRASEÑAS#{C::RESET} #{C::CYAN}║#{C::RESET}
#{C::CYAN}║#{C::RESET} #{C::GREEN}✓ CORRECTO:#{C::RESET} ruby candados.rb run <clave> '<cmd>' #{C::CYAN}║#{C::RESET}
#{C::CYAN}║#{C::RESET} #{C::RED}✗ INCORRECTO:#{C::RESET} ruby candados.rb get <clave> #{C::CYAN}║#{C::RESET}
#{C::CYAN}║#{C::RESET} → `run` inyecta USR/PASS sin mostrar. `get` imprime y deja rastro. #{C::CYAN}║#{C::RESET}
#{C::CYAN}╚════════════════════════════════════════════════════════════════╝#{C::RESET}
RECORDATORIO
puts
end
# ─── Main ────────────────────────────────────────────────────────────
if __FILE__ == $0
comando = ARGV[0]
arg = ARGV[1]
case comando
when 'authorize'
autorizar!
when 'cerrar'
cerrar_sesion!
when 'abrir'
cripto = Cripto.new(cargar_master_key)
boveda = Boveda.new(BOVEDA)
cmd_abrir(cripto, boveda)
when 'sudo'
cripto = Cripto.new(cargar_master_key)
boveda = Boveda.new(BOVEDA)
cmd_sudo(cripto, boveda, ARGV[1..])
when 'ssh'
cripto = Cripto.new(cargar_master_key)
boveda = Boveda.new(BOVEDA)
cmd_ssh(cripto, boveda, ARGV[1], ARGV[2], *ARGV[3..])
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', '-h'
cmd_help_completo
when 'help', 'ayuda', nil
cmd_help_resumida
else
$stderr.puts "#{C::RED}✗ Comando desconocido: '#{comando}'#{C::RESET}"
$stderr.puts ""
cmd_help_resumida
exit 1
end
end