#!/usr/bin/env ruby # frozen_string_literal: true # ns8-candados — Gestión unificada de secretos y autorización SSH # Uso: ruby tools/ns8-candados/ns8-candados.rb [args] # # Comandos: # abrir Autorizar SSH (cargar llave con passphrase) # encrypt Cifrar texto plano # decrypt Descifrar payload # get Obtener secreto de la bóveda (salida: solo el valor) # set Guardar secreto (pide input interactivo, sin eco) # rm 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__) PROJECT_DIR = File.expand_path('../..', TOOL_DIR) MASTER_KEY = File.join(PROJECT_DIR, '.master.key') BOVEDA = File.join(TOOL_DIR, '.boveda.json') # ─── 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 #{MASTER_KEY}#{C::RESET}" $stderr.puts "#{C::YELLOW} NO subir a Git (ya cubierta por .gitignore).#{C::RESET}" end File.read(MASTER_KEY) 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? token = boveda.get(clave) abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" if token.nil? # Salida limpia (solo el valor) para uso en scripts: # PASS=$(ruby tools/ns8-candados/ns8-candados.rb get admindasu) puts cripto.decrypt(token) 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 [args] #{C::YELLOW}Comandos:#{C::RESET} abrir Autorizar SSH (cargar passphrase) encrypt Cifrar texto plano → token decrypt Descifrar token → texto plano get Obtener secreto de la bóveda set Guardar secreto (input seguro) rm Eliminar secreto list Listar claves disponibles #{C::YELLOW}Ejemplos:#{C::RESET} #{C::DIM}# Guardar un secreto#{C::RESET} ruby ns8-candados.rb set mi_password #{C::DIM}# Usar en scripts (salida limpia)#{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 'abrir' cmd_abrir when 'encrypt', 'decrypt', 'get', '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 '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