diff --git a/.gitignore b/.gitignore index 7e071da0..3ebaec9c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,7 @@ logs/ tmp/ .temp/ .cache/ -scripts/.agent_token -scripts/.agent_token -scripts/.agent_secrets.json -scripts/seguridad/.agent_secrets.json + +# Seguridad (ns8-candados) +tools/ns8-candados/.boveda.json .agent_token diff --git a/adn/03_seguridad.md b/adn/03_seguridad.md index eee683d3..e969200f 100644 --- a/adn/03_seguridad.md +++ b/adn/03_seguridad.md @@ -2,7 +2,7 @@ > Referencia canónica: [`adn/00_indice.md`](00_indice.md) -Esta hebra gobierna los protocolos de seguridad de red, gestión de secretos, autorización SSH y contingencia ante fallos críticos de conectividad. +Esta hebra gobierna los protocolos de seguridad de red, gestión de secretos y autorización SSH. ## 1. Protocolo de Seguridad de Red (Hombre Muerto) @@ -16,78 +16,73 @@ Esta hebra gobierna los protocolos de seguridad de red, gestión de secretos, au 3. **Si no hay confirmación**: Revertir cambios inmediatamente y restaurar estado anterior. 4. **Si hay confirmación**: Hacer cambios persistentes. -## 2. Gestión de Secretos (Bóveda Operativa) +## 2. Gestión de Secretos — `ns8-candados` -Todos los artefactos de seguridad residen en `scripts/seguridad/`. +Herramienta unificada de seguridad: [`tools/ns8-candados/ns8-candados.rb`](../tools/ns8-candados/ns8-candados.rb) ### 2.1 Prohibición de Texto Plano La Inteligencia Artificial operante tiene **expresamente prohibido** solicitar contraseñas administrativas, tokens o claves privadas al operador, así como también plasmarlas en logs, comandos literales temporales o en la bitácora. -### 2.2 Componentes de la Bóveda +### 2.2 Componentes | Archivo | Ubicación | Git | Descripción | | :--- | :--- | :---: | :--- | -| `secret_box.rb` | `scripts/seguridad/` | ✅ | Motor de cifrado AES-256-GCM. Encripta/desencripta payloads. | -| `.agent_secrets.json` | `scripts/seguridad/` | ❌ | Payloads cifrados (sudo, rsa, domain_admin). | +| `ns8-candados.rb` | `tools/ns8-candados/` | ✅ | Script Ruby unificado: cifrado, bóveda y autorización SSH. | +| `.boveda.json` | `tools/ns8-candados/` | ❌ | Payloads AES-256-GCM cifrados. | | `.master.key` | Raíz del proyecto | ❌ | Clave maestra de la bóveda (nunca en Git). | -### 2.3 Consumo Efímero de Bóveda - -Ante requerimientos de escalada de privilegios (`sudo`, `root`, `sshpass`), la IA accederá de forma autónoma a la bóveda: +### 2.3 Comandos Disponibles ```bash -# Ejemplo: ejecutar comando con privilegios en un nodo remoto -CMD_PASS=$(ruby scripts/seguridad/secret_box.rb decrypt "$PAYLOAD") -sshpass -p "$CMD_PASS" ssh root@10.0.10.205 "comando" +ruby tools/ns8-candados/ns8-candados.rb [args] ``` -**Payloads disponibles** (en `.agent_secrets.json`): -- `sudo`: Contraseña de sudo/root del entorno local. -- `rsa`: Passphrase de la llave SSH (`~/.ssh/id_rsa`). -- `domain_admin`: Credenciales de administrador del dominio `dasuten.utnlr`. +| Comando | Descripción | +| :--- | :--- | +| `abrir` | Autorizar SSH (cargar llave con passphrase, sin timeout). | +| `encrypt ` | Cifrar texto plano → token. | +| `decrypt ` | Descifrar token → texto plano. | +| `get ` | Obtener secreto de la bóveda (salida limpia para pipelines). | +| `set ` | Guardar secreto (input interactivo sin eco). | +| `rm ` | Eliminar secreto de la bóveda. | +| `list` | Listar claves disponibles. | -## 3. Autorización SSH (Candado) +### 2.4 Consumo Efímero (IA) + +```bash +# Obtener password para uso en comando remoto +PASS=$(ruby tools/ns8-candados/ns8-candados.rb get admindasu) +sshpass -p "$PASS" ssh -J root@10.0.10.205 admindasu@10.0.100.12 "cmd" +``` + +### 2.5 Payloads en la Bóveda + +| Clave | Propósito | +| :--- | :--- | +| `sudo` | Contraseña de sudo/root del entorno local. | +| `rsa` | Passphrase de la llave SSH (`~/.ssh/id_rsa`). | +| `domain_admin` | Credenciales del Administrador del dominio `dasuten.utnlr`. | +| `admindasu` | Credenciales del usuario `admindasu` (Domain Admin). | + +## 3. Autorización SSH ### 3.1 Problema -La llave SSH del operador está protegida con passphrase. El agente SSH no retiene las identidades entre sesiones. La IA no puede inyectar la passphrase de forma no interactiva sin `expect`. +La llave SSH del operador está protegida con passphrase. El agente SSH no retiene las identidades entre sesiones. -### 3.2 Solución: `candado.rb` -El operador ejecuta el script **interactivamente** para autorizar a la IA durante una ventana de tiempo: +### 3.2 Solución: `ns8-candados.rb abrir` +El operador ejecuta el comando **interactivamente** para cargar su identidad SSH: ```bash -ruby scripts/seguridad/candado.rb [minutos] +ruby tools/ns8-candados/ns8-candados.rb abrir ``` -**Flujo:** -1. 🧹 Limpia identidades previas del agente SSH (`ssh-add -D`). -2. 🔑 Pide la passphrase al operador (`ssh-add`). -3. ✅ Verifica que la llave se cargó correctamente. -4. 🔓 Genera un token temporal (`.agent_token`, 5 min por defecto). -5. La IA puede ahora ejecutar comandos SSH sin intervención. - -### 3.3 Flujo Operativo Completo - -``` -OPERADOR IA (Agente) - │ │ - │ ruby candado.rb 10 │ - │ → Ingresa passphrase │ - │ → Token activo 10 min │ - │ │ - │ "Listo, el candado está abierto"│ - │─────────────────────────────────→│ - │ │ ssh root@srv-dasu "qm create..." - │ │ (usa llave cargada en agente) - │ │ - │ ⏳ Token expira │ - │ │ ssh → ❌ (sin identidad) -``` +Las identidades permanecen activas mientras dure la sesión del agente SSH (sin timeout artificial). ## 4. Archivos Protegidos (`.gitignore`) Los siguientes artefactos **nunca** deben ser rastreados por Git: - `.master.key` (clave maestra de la bóveda) -- `scripts/seguridad/.agent_secrets.json` (payloads cifrados) -- `.agent_token` (token temporal de autorización) +- `tools/ns8-candados/.boveda.json` (payloads cifrados) +- `.agent_token` (token legacy, obsoleto) - `*.key` (claves privadas) - `tmp/` (artefactos transitorios de la IA) diff --git a/bitacoras/2026-02-28.md b/bitacoras/2026-02-28.md index a8f1f029..ee9a2530 100644 --- a/bitacoras/2026-02-28.md +++ b/bitacoras/2026-02-28.md @@ -12,6 +12,7 @@ ### ⏳ En Proceso | ID | NODO | DETALLE | | :--- | :--- | :--- | +| [✅ - SEC01 - **Refactorización ns8-candados**](#srv-ns8) | srv-ns8 | Unificación de scripts de seguridad en `tools/ns8-candados/`. | ### 📝 Resumen de Actividades | NODO | RESUMEN INTEGRAL | @@ -22,6 +23,15 @@ ## 📂 Actividades Detalladas +### srv-ns8 + +#### ✅ - SEC01 - Refactorización ns8-candados +🔐 Unificación de `candado.rb`, `secret_box.rb` y `.agent_secrets.json` en una sola herramienta Ruby `ns8-candados.rb`. + +| Tiempo | Descripción | +| :--- | :--- | +| ✅ 08:34 | (IA) 🔐 Refactorización completada. Nuevo script unificado con subcomandos (`abrir`, `get`, `set`, `encrypt`, `decrypt`, `list`, `rm`). Bóveda migrada. ADN actualizado. Scripts legacy eliminados. `[Físico: 0:30 hs]` | + ### Operaciones Centrales #### 👁️ - SINC01 - Sincronización de Inicio de Jornada diff --git a/scripts/seguridad/candado.rb b/scripts/seguridad/candado.rb deleted file mode 100755 index e13eb8f8..00000000 --- a/scripts/seguridad/candado.rb +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env ruby -# script: candado.rb -# Uso: ruby scripts/candado.rb [minutos] -# Descripción: Genera un token de autorización temporal para que el Agente pueda ejecutar tareas críticas. -# Requiere que el usuario tenga claves SSH cargadas (prueba de identidad). - -require 'json' -require 'fileutils' - -TOKEN_FILE = File.expand_path('../../.agent_token', __dir__) -DEFAULT_DURATION_MINS = 5 - -# 1. Validación de Identidad (Proof of User Presence) -puts "🔐 Requerimiento de Protocolo: Autorización FRESCA." -puts "🧹 Limpiando identidades previas en el agente SSH para exigir validación..." -system("ssh-add -D > /dev/null 2>&1") # Borra claves cacheadas para forzar el prompt - -puts "🔑 Por favor, ingresa tu passphrase para autorizar al Agente:" -# Intentamos cargar las claves interactivamente (ahora sí pedirá pass porque borramos las anteriores) -system("ssh-add") - -# Verificamos que la carga haya sido exitosa -system("ssh-add -l > /dev/null 2>&1") -unless $?.success? - puts "❌ ERROR: No se detectaron identidades SSH." - puts " Debes completar el desafío de identidad (passphrase) para continuar." - exit 1 -end - -# 2. Generación del Token -duration = (ARGV[0] || DEFAULT_DURATION_MINS).to_i -expires_at = Time.now + (duration * 60) - -token_data = { - created_at: Time.now.to_s, - expires_at: expires_at.to_s, - duration_mins: duration, - authorized_by: ENV['USER'] || 'unknown', - signature: rand(36**8).to_s(36) # Firma aleatoria simple -} - -File.write(TOKEN_FILE, JSON.pretty_generate(token_data)) -FileUtils.chmod(0600, TOKEN_FILE) # Solo lectura para el usuario - -puts "\n🔓 VACANCIA DE SEGURIDAD ACTIVADA" -puts "--------------------------------" -puts "✅ Agente AUTORIZADO para operaciones críticas." -puts "⏳ Ventana de tiempo: #{duration} minutos." -puts "💀 Expira: #{expires_at.strftime('%H:%M:%S')}" -puts "🔑 Token: #{TOKEN_FILE}" -puts "\n>> Ahora puedes pedirle al Agente que ejecute sus tareas." diff --git a/scripts/seguridad/secret_box.rb b/scripts/seguridad/secret_box.rb deleted file mode 100755 index 05100f0b..00000000 --- a/scripts/seguridad/secret_box.rb +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require 'openssl' -require 'base64' -require 'json' - -class SecretBox - ALGORITHM = 'aes-256-gcm'.freeze - - def initialize(master_key_content) - # Generar una clave de 32 bytes usando SHA256 sobre el contenido del master_key - @key = Digest::SHA256.digest(master_key_content.strip) - end - - def encrypt(plaintext) - cipher = OpenSSL::Cipher.new(ALGORITHM) - cipher.encrypt - cipher.key = @key - iv = cipher.random_iv - - ciphertext = cipher.update(plaintext.to_s) + cipher.final - auth_tag = cipher.auth_tag - - # Base64 encodear las 3 partes para un JSON - payload = { - iv: Base64.strict_encode64(iv), - tag: Base64.strict_encode64(auth_tag), - data: Base64.strict_encode64(ciphertext) - } - - Base64.strict_encode64(payload.to_json) - end - - def decrypt(encrypted_payload) - parsed_payload = JSON.parse(Base64.strict_decode64(encrypted_payload), symbolize_names: true) - - cipher = OpenSSL::Cipher.new(ALGORITHM) - cipher.decrypt - cipher.key = @key - cipher.iv = Base64.strict_decode64(parsed_payload[:iv]) - cipher.auth_tag = Base64.strict_decode64(parsed_payload[:tag]) - - cipher.update(Base64.strict_decode64(parsed_payload[:data])) + cipher.final - rescue => e - raise "Error al desencriptar. Clave incorrecta o payload dañado. Detalles: #{e.message}" - end -end - -if __FILE__ == $0 - action = ARGV[0] - - unless ['encrypt', 'decrypt'].include?(action) - puts "Uso:" - puts " ruby #{$0} encrypt " - puts " ruby #{$0} decrypt " - exit 1 - end - - text = ARGV[1] - if text.nil? || text.empty? - puts "Por favor, proporciona el texto a #{action}." - puts "Ejemplo:" - puts " ruby #{$0} encrypt \"MiSuperPassword\"" - exit 1 - end - - # Buscar el archivo .master.key en el root del proyecto - key_path = File.join(File.expand_path('../..', __dir__), '.master.key') - - unless File.exist?(key_path) - require 'securerandom' - File.write(key_path, SecureRandom.hex(32)) - puts "⚠️ ATENCIÓN: Se ha creado una nueva clave maestra en '#{key_path}'" - puts " Asegúrate de NO subir este archivo a ningún repositorio (ya está cubierto por **/*.key en .gitignore)." - puts " Cualquiera que tenga este archivo podrá desencriptar los textos.\n\n" - end - - box = SecretBox.new(File.read(key_path)) - - begin - if action == 'encrypt' - puts box.encrypt(text) - else - puts box.decrypt(text) - end - rescue => e - puts "Error: #{e.message}" - exit 1 - end -end diff --git a/tools/ns8-candados/ns8-candados.rb b/tools/ns8-candados/ns8-candados.rb new file mode 100755 index 00000000..348aebdd --- /dev/null +++ b/tools/ns8-candados/ns8-candados.rb @@ -0,0 +1,222 @@ +#!/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