#!/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 '' # β†’ Inyecta USR y PASS en el entorno del subproceso. Nada se imprime. # # COMANDO SECUNDARIO (USO INTERACTIVO): # eval $(ruby candados.rb load ) # β†’ 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 [args] # # Comandos principales: # run Ejecutar comando con USR y PASS inyectados (PRIMARIO) # sudo Ejecutar comando con sudo desde la bΓ³veda # load Cargar USR/PASS en shell (eval, archivo temporal seguro) # # Comandos de gestiΓ³n: # authorize Abrir candado (sesiΓ³n temporal 5 min) # set Guardar secreto (input seguro, sin eco) # get Obtener secreto (⚠ imprime en stdout) # rm 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 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: contiene user y para pass, # o _user y _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. ::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 [args] #{C::YELLOW}━━━ Comandos principales ━━━#{C::RESET} #{C::BOLD}run #{C::RESET} #{C::GREEN}β˜… PRIMARIO#{C::RESET} Ejecutar comando con USR y PASS inyectados #{C::BOLD}sudo #{C::RESET} Ejecutar con privilegios (resuelve clave automΓ‘ticamente) #{C::BOLD}load #{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 Guardar secreto (input seguro, sin eco) get Obtener secreto (⚠ imprime a stdout) rm Eliminar secreto list Listar claves disponibles #{C::YELLOW}━━━ Utilidades ━━━#{C::RESET} abrir Autorizar SSH (cargar passphrase) encrypt Cifrar texto plano β†’ token decrypt 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} ::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