[Tools] ns8-candados: herramienta unificada de seguridad (reemplaza candado.rb + secret_box.rb)

This commit is contained in:
Ricardo Monla
2026-02-28 08:37:44 -03:00
parent 72f1944090
commit 3555b788e7
6 changed files with 277 additions and 193 deletions
+3 -4
View File
@@ -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
+42 -47
View File
@@ -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 <comando> [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 <texto>` | Cifrar texto plano → token. |
| `decrypt <token>` | Descifrar token → texto plano. |
| `get <clave>` | Obtener secreto de la bóveda (salida limpia para pipelines). |
| `set <clave>` | Guardar secreto (input interactivo sin eco). |
| `rm <clave>` | 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)
+10
View File
@@ -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
-51
View File
@@ -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."
-91
View File
@@ -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 <texto_plano>"
puts " ruby #{$0} decrypt <texto_encriptado>"
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
+222
View File
@@ -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 <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)
# 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__)
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 <comando> [args]
#{C::YELLOW}Comandos:#{C::RESET}
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
set <clave> Guardar secreto (input seguro)
rm <clave> 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