Refactor(ADN): Migración de tools/adn a adn/tools en busca de la Armonía Integral del DIIAA
This commit is contained in:
Executable
+319
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ns8-candados — Gestión unificada de secretos y autorización SSH
|
||||
# ==============================================================================
|
||||
# 🤖 INSTRUCCIONES PARA LA IA (AGENTES EXTERNOS):
|
||||
# Esta herramienta permite obtener credenciales seguras sin exponerlas en el chat.
|
||||
# PARA USARLA CORRECTAMENTE (SIN FILTRAR SECRETOS):
|
||||
# Ejecuta el comando y asigna su salida a una variable silenciada:
|
||||
# PASS=$(ruby tools/ns8-candados/ns8-candados.rb get nombre_clave)
|
||||
# sshpass -p "$PASS" ssh usuario@servidor
|
||||
# NUNCA ejecutes `ruby ns8-candados.rb get clave` directamente en run_command sin
|
||||
# capturar o redirigir el output, porque el secreto se filtrará en la bitácora del LLM.
|
||||
# ==============================================================================
|
||||
# Uso: ruby tools/ns8-candados/ns8-candados.rb <comando> [args]
|
||||
#
|
||||
# Comandos:
|
||||
# abrir Autorizar SSH (cargar llave con passphrase)
|
||||
# encrypt <txt> Cifrar texto plano
|
||||
# decrypt <tok> Descifrar payload
|
||||
# get <clave> Obtener secreto de la bóveda (salida: solo el valor)
|
||||
# run <cl> <vr> <cmd> Ejecutar comando con secreto en variable de entorno <vr>
|
||||
# set <clave> Guardar secreto (pide input interactivo, sin eco)
|
||||
# rm <clave> Eliminar secreto de la bóveda
|
||||
# list Listar claves disponibles en la bóveda
|
||||
|
||||
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"
|
||||
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}🔐 ns8-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, env_var, comando)
|
||||
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
|
||||
abort "#{C::RED}✗ Falta nombre de variable de entorno.#{C::RESET}" if env_var.nil?
|
||||
abort "#{C::RED}✗ Falta comando a ejecutar.#{C::RESET}" if comando.nil? || comando.empty?
|
||||
|
||||
verificar_sesion!
|
||||
token = boveda.get(clave)
|
||||
abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" if token.nil?
|
||||
|
||||
secret = cripto.decrypt(token)
|
||||
registrar_acceso('RUN', "Clave: #{clave}, Comando: #{comando.is_a?(Array) ? comando.join(' ') : comando}")
|
||||
|
||||
# Ejecutar el comando inyectando la variable
|
||||
ENV[env_var] = secret
|
||||
begin
|
||||
# Si comando es un string, lo ejecutamos vía shell, si es array, directo
|
||||
if comando.is_a?(Array)
|
||||
system(*comando)
|
||||
else
|
||||
system(comando)
|
||||
end
|
||||
exit $?.exitstatus || 0
|
||||
ensure
|
||||
ENV.delete(env_var)
|
||||
end
|
||||
end
|
||||
|
||||
def cmd_set(cripto, boveda, clave)
|
||||
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
|
||||
$stderr.print "#{C::YELLOW}🔑 Valor para '#{clave}': #{C::RESET}"
|
||||
valor = $stdin.noecho(&:gets)&.chomp
|
||||
$stderr.puts
|
||||
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_help
|
||||
puts <<~HELP
|
||||
#{C::CYAN}🔐 ns8-candados — Gestión de secretos#{C::RESET}
|
||||
|
||||
#{C::GREEN}Uso:#{C::RESET} ruby ns8-candados.rb <comando> [args]
|
||||
|
||||
#{C::YELLOW}Comandos:#{C::RESET}
|
||||
authorize Abrir candado (autorización temporal 5 min)
|
||||
cerrar Cerrar candado manual (eliminar sesión)
|
||||
abrir Autorizar SSH (cargar passphrase)
|
||||
encrypt <texto> Cifrar texto plano → token
|
||||
decrypt <token> Descifrar token → texto plano
|
||||
get <clave> Obtener secreto de la bóveda
|
||||
run <cl> <vr> <cmd> Ejecutar comando con secreto en variable <vr>
|
||||
set <clave> Guardar secreto (input seguro)
|
||||
rm <clave> Eliminar secreto
|
||||
list Listar claves disponibles
|
||||
|
||||
#{C::YELLOW}Ejemplos:#{C::RESET}
|
||||
# Autorizar para operar
|
||||
ruby ns8-candados.rb authorize
|
||||
|
||||
#{C::DIM}# Usar de forma segura inyectando en el entorno (recomendado)#{C::RESET}
|
||||
ruby ns8-candados.rb run admindasu PASS "sshpass -e ssh user@host"
|
||||
|
||||
#{C::DIM}# Usar en scripts (salida limpia - menos seguro)#{C::RESET}
|
||||
PASS=$(ruby ns8-candados.rb get admindasu)
|
||||
sshpass -p "$PASS" ssh user@host "cmd"
|
||||
HELP
|
||||
end
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────
|
||||
comando = ARGV[0]
|
||||
arg = ARGV[1]
|
||||
|
||||
case comando
|
||||
when 'authorize'
|
||||
autorizar!
|
||||
when 'cerrar'
|
||||
cerrar_sesion!
|
||||
when 'encrypt', 'decrypt', 'get', '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 'run' then cmd_run(cripto, boveda, arg, ARGV[2], ARGV[3..].join(' '))
|
||||
when 'set' then cmd_set(cripto, boveda, arg)
|
||||
when 'rm' then cmd_rm(boveda, arg)
|
||||
when 'list' then cmd_list(boveda)
|
||||
end
|
||||
when 'help', '-h', '--help', nil
|
||||
cmd_help
|
||||
else
|
||||
$stderr.puts "#{C::RED}✗ Comando desconocido: '#{comando}'#{C::RESET}"
|
||||
cmd_help
|
||||
exit 1
|
||||
end
|
||||
Reference in New Issue
Block a user