Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
797c37d312 | ||
|
|
398d01fd4c | ||
|
|
6ddf76983b | ||
|
|
88b2ad2980 | ||
|
|
34ee25b32f |
@@ -0,0 +1,22 @@
|
||||
# Propuesta: claves por agente y por host (dejar de usar la clave maestra)
|
||||
|
||||
Problema: la clave ED25519 gustavo@blinkbtc.com (~/.ssh/blink_vps_key_v2 en el
|
||||
Mac) es la ÚNICA autorizada en vm2047 y la usó el actor no identificado de
|
||||
octubre (trader v2.4, burbujas, sfeed, respondedor). Un solo secreto, todo el
|
||||
poder, cero trazabilidad por agente.
|
||||
|
||||
Propuesta (por orden de adopción):
|
||||
1. Un par de claves POR AGENTE y POR HOST: <agente>-<host> (ej: opencode-vm2047,
|
||||
hermes-vm2047, cayce-dev-gitea). ed25519, sin passphrase, en ~/.ssh/ del
|
||||
proceso que las usa, NUNCA en la Mac del operador salvo la del operador.
|
||||
2. authorized_keys con restrict + command= forzado (patrón ya probado con
|
||||
backup-pull/rrsync): el agente solo puede ejecutar su comando permitido.
|
||||
3. Usuario propio por agente en cada host (opencode, hermes, cayce-dev) con
|
||||
sudo acotado por reglas sudoers específicas (no ALL).
|
||||
4. Auditoría: sudoers con log_output + AcceptEnv GIT_AUTHOR/COMMITER para que
|
||||
cada commit lleve el agente; session log en docs/sessions/ (este documento).
|
||||
5. Rotación de salida: retirar gustavo@blinkbtc.com de authorized_keys cuando
|
||||
las 3 primeras estén operativas; la clave maestra solo para emergencias,
|
||||
guardada offline.
|
||||
6. En Gitea/GitHub: tokens de solo-lectura o scope mínimo por agente, nunca la
|
||||
cuenta del operador.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Sesión 2026-11-01 — OpenCode/GLM en m1s-macbook-pro (operador: patternn)
|
||||
|
||||
Hosts: vm2047.lnvps.cloud (ssh lolvm), blink-vps (lectura), GitHub (gh, patternn), Gitea git.cayce-co.xyz.
|
||||
|
||||
## Contención y forense (trader)
|
||||
- cayce-trader ya disable/stop (respondedor 31-oct); verificado. /etc/hosts: 16 endpoints Binance a 0.0.0.0, curl sin conexión.
|
||||
- Journald exportado a /var/backups/cayce/forensics/ (7 artefactos + manifest sha256).
|
||||
- Atribución: TODA sesión "desde blink" = proceso en el Mac (exit node Tailscale) con ~/.ssh/blink_vps_key_v2. v2.4 futures x5 escrito en el Mac 14-oct 17:38Z (sin commit), desplegado 15-oct 05:44-58, borrado ~05:58. Harness no identificado. Informes: FORENSICS-REPORT-20261101.md, ACTIONS-20261101.md.
|
||||
|
||||
## Infra
|
||||
- Gitea TLS https://git.cayce-co.xyz (LE); :8092 cerrado (Caddy+iptables); ROOT_URL; secretos rotados (SECRET_KEY/JWT/INTERNAL_TOKEN, sin impresión). Usuario pattern (merge exclusivo) + script gitea-set-merge-owner.
|
||||
- Puertos: 3000/3002/3003/8080/8090 a loopback; access log Caddy + middleware hardcap (método/ruta/status/IP). 8080 era host-matcher malentendido, no bind.
|
||||
|
||||
## Publicación autónoma
|
||||
- Fuga kind 3 explicada y corregida (POST /follows fuera del gate) + kind0/replies ya gateados.
|
||||
- guard.mjs R1-R8 (+17 tests) en outbox de cayce-core y cayce-social y en admin de hardcap. Rechazos → guard_rejections.
|
||||
- Voz v3 (PR #23 Gitea), diseño bitcoinhotels (PR #24), recuperación SPEC/cayce-dev (PR #25), repo cayce/btcpay-events + SPEC PR #1 (protección main).
|
||||
- Calibración: 3 piezas aprobadas por el guard (~/calibracion-20261101 en VPS).
|
||||
- ACTIVADO live 2026-11-01 20:19 UTC (4 .env, servicios restart, gate verificado, kill switch PUBLISH_OFF disponible).
|
||||
|
||||
## Backups
|
||||
- age recipient nuevo (Mac ~/.config/cayce-backup/age.key, secreto jamás impreso) + clave backup-pull instalada con rrsync -ro. Bugs: fallback PGURL, GRANT posts_log, NoNewPrivileges. Primer backup OK (3 .age, 1.6M) y pull del Mac verificado (manifest 3xOK). Cadena: VPS 03:15 UTC / Mac 23:30 + launchd.
|
||||
|
||||
## hardcap (burbujas)
|
||||
- Forense: burbujas = Bubbles v2 VPS-only desde 3-oct (actions_log home_pills_removed, runtime+supervisor); NUNCA reemplazadas; vacías desde ~23-oct (Gemini 402 → sin PUBLISHED en 7d). 402 exacto: prepayment credits depleted (AI Studio), key válida.
|
||||
- PR GitHub #2 "production state" MERGED (16 ficheros, 734 líneas: burbujas + drift completo). PR #1 (treemap, premisa errónea) CLOSED.
|
||||
- /srv/hardcap-news = checkout git único para FE+BE (swap con rollback .pre-git-20261101; health 200; working tree limpio). Timer cayce-check-clean-trees diario 09:10 UTC.
|
||||
- scripts/reprocess-pending.ts añadido (para cuando vuelvan créditos AI Studio).
|
||||
|
||||
## IG
|
||||
- META_PAGE_TOKEN del .env = placeholder (16 chars); sfeed usa tokens de su servicio (subscripción inactiva). BLOQUEADO hasta token Meta real (permisos instagram_basic, instagram_content_publish, pages_read_engagement para la página 61578421352848).
|
||||
|
||||
## Pendientes del operador
|
||||
- Recargar créditos AI Studio (aistudio.google.com → Billing) y correr reprocess.
|
||||
- Token Meta para IG. Rotar tokens automation/automation2 de Gitea.
|
||||
- Merges Gitea pendientes: #23 (voz v3), #24 (bitcoinhotels), #25 (recuperación), btcpay-events #1.
|
||||
@@ -25,5 +25,8 @@ for ENVF in /srv/cayce-core/.env /srv/cayce-nostr/.env /home/ubuntu/cayce-social
|
||||
grep -q "^IDENTITY_PATH=" "$ENVF" 2>/dev/null || echo "IDENTITY_PATH=/home/ubuntu/cayce-ops/prompts/cayce-identity.md" >> "$ENVF"
|
||||
done
|
||||
|
||||
sudo install -m 644 '$SRC/hardcap/admin.ts' /srv/hardcap-backend/src/routes/admin.ts 2>/dev/null || true
|
||||
install -m 755 '$SRC/hardcap/test-admin-auth.mjs' /srv/hardcap-backend/runtime-tests/test-admin-auth.mjs 2>/dev/null || true
|
||||
sudo systemctl restart hardcap-backend
|
||||
sudo systemctl restart cayce-nostr
|
||||
echo "deploy OK — mode=$(grep -h "^CAYCE_PUBLISH_MODE=" /srv/cayce-core/.env | head -1)"
|
||||
|
||||
@@ -4,6 +4,29 @@ import { ingestAllSources } from '../services/ingestion'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ── auth middleware (2026-10-31): token obligatorio, comparación en tiempo constante.
|
||||
// Fail-closed: sin HARDCAP_ADMIN_TOKEN configurado, TODAS las rutas admin responden 503.
|
||||
import { createHash, timingSafeEqual } from 'crypto'
|
||||
|
||||
const ADMIN_TOKEN = process.env.HARDCAP_ADMIN_TOKEN || ''
|
||||
function _safeEqual(a: string, b: string): boolean {
|
||||
// hash de ambos a longitud fija: sin leak de longitud ni de timing por contenido
|
||||
return timingSafeEqual(createHash('sha256').update(String(a)).digest(), createHash('sha256').update(String(b)).digest())
|
||||
}
|
||||
function adminAuth(req: any, res: any, next: any) {
|
||||
if (!ADMIN_TOKEN) {
|
||||
console.error('[admin-auth] HARDCAP_ADMIN_TOKEN not set — admin request REFUSED (fail-closed)')
|
||||
return res.status(503).json({ error: 'admin disabled: token not configured' })
|
||||
}
|
||||
const provided = String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '') || String(req.headers['x-admin-token'] || '')
|
||||
if (provided && _safeEqual(provided, ADMIN_TOKEN)) return next()
|
||||
console.error(`[admin-auth] 401 ${req.method} ${req.originalUrl} from ${req.socket.remoteAddress}`)
|
||||
return res.status(401).json({ error: 'unauthorized' })
|
||||
}
|
||||
router.use(adminAuth)
|
||||
// regla permanente de secretos: jamás loguear el token ni volcar headers.
|
||||
|
||||
|
||||
// ── Articles ──────────────────────────────────────────────
|
||||
|
||||
// GET /admin/articles?status=QUEUED&topic=lightning
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// test-admin-auth — verifica el middleware de /admin (hardcap-backend).
|
||||
// Ejecutar en la VPS: node /srv/hardcap-backend/runtime-tests/test-admin-auth.mjs
|
||||
// (o node runtime/hardcap/test-admin-auth.mjs desde el repo)
|
||||
// Requiere HARDCAP_ADMIN_TOKEN en el .env del servicio y el servicio activo en 127.0.0.1:3001.
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const BASE = 'http://127.0.0.1:3001'
|
||||
const env = Object.fromEntries(
|
||||
readFileSync('/srv/hardcap-backend/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||
)
|
||||
const TOKEN = env.HARDCAP_ADMIN_TOKEN
|
||||
if (!TOKEN) { console.error('HARDCAP_ADMIN_TOKEN no configurado — el test no puede pasar'); process.exit(2) }
|
||||
|
||||
let failed = 0
|
||||
async function check(name, path, opts, expected) {
|
||||
const r = await fetch(BASE + path, { ...opts, signal: AbortSignal.timeout(8000) })
|
||||
const ok = r.status === expected
|
||||
if (!ok) failed++
|
||||
console.log(`${ok ? '✓' : '✗'} ${name}: esperado ${expected}, obtenido ${r.status}`)
|
||||
}
|
||||
|
||||
await check('sin token → 401', '/admin/articles?status=QUEUED', {}, 401)
|
||||
await check('token incorrecto → 401', '/admin/articles?status=QUEUED', { headers: { Authorization: 'Bearer ' + 'x'.repeat(64) } }, 401)
|
||||
await check('token vacío en header → 401', '/admin/sources', { headers: { Authorization: 'Bearer ' } }, 401)
|
||||
await check('token correcto → 200', '/admin/articles?status=QUEUED', { headers: { Authorization: `Bearer ${TOKEN}` } }, 200)
|
||||
await check('header alternativo X-Admin-Token → 200', '/admin/sources', { headers: { 'x-admin-token': TOKEN } }, 200)
|
||||
// la comparación debe ser en tiempo constante: un token de la misma longitud pero distinto NO pasa
|
||||
await check('token misma longitud, distinto → 401', '/admin/articles?status=QUEUED', { headers: { Authorization: 'Bearer ' + 'y'.repeat(TOKEN.length) } }, 401)
|
||||
|
||||
console.log(failed ? `\n${failed} TESTS FALLARON` : '\nTODOS LOS TESTS PASARON')
|
||||
process.exit(failed ? 1 : 0)
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-credential-leaks — falla si journald reciente contiene patrones de credencial.
|
||||
# Regla permanente: los secretos jamás en logs. Ejecutar manualmente o desde cron
|
||||
# (sin timer propio: no fue autorizado).
|
||||
set -euo pipefail
|
||||
SINCE="${1:-2 hours ago}"
|
||||
HITS=$(journalctl --since "$SINCE" --no-pager 2>/dev/null | grep -cE "(postgres|postgresql)://[A-Za-z0-9_]+:[^@[:space:]]{8,}@|nsec1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{50,}" || true)
|
||||
if [ "$HITS" -gt 0 ]; then
|
||||
echo "ALERTA: $HITS líneas de journald (desde $SINCE) contienen patrones de credencial" >&2
|
||||
journalctl --since "$SINCE" --no-pager 2>/dev/null | grep -nE "(postgres|postgresql)://[A-Za-z0-9_]+:[^@[:space:]]{8,}@|nsec1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{50,}" | sed -E "s#(://[A-Za-z0-9_]+:)[^@[:space:]]{8,}@#\1***@#" | head -5
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: sin patrones de credencial en journald desde $SINCE"
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Regla permanente (2026-10-31): ningún fichero versionado contiene patrones
|
||||
de credenciales. Placeholders de .env.example están permitidos.
|
||||
|
||||
Fallará si alguien mete un DSN con contraseña real, un token en claro, o
|
||||
cualquier valor que deba vivir solo en .env / tokens.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
SCAN_DIRS = ["runtime", "deploy", "docs", "scripts", "prompts"]
|
||||
SCAN_GLOBS = ["*.py", "*.mjs", "*.js", "*.ts", "*.md", "*.yml", "*.yaml", "*.sh", "*.sql"]
|
||||
|
||||
# DSN con credencial literal (user:pass@host) — excluye placeholders conocidos
|
||||
DSN = re.compile(r"(postgres|postgresql|mysql|mongodb)(\+[a-z]+)?://[A-Za-z0-9_]+:(?!password\b|change-me\b|<[^>]+>)[^@\s/]{8,}@", re.I)
|
||||
# tokens hex largos fuera de tests (los fixtures de tests están allowlistados por diseño)
|
||||
HEXTOKEN = re.compile(r"\b[0-9a-f]{64}\b")
|
||||
# nsec bech32
|
||||
NSEC = re.compile(r"nsec1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{50,}")
|
||||
ALLOW_SUBSTR = ("user:password", "change-me", "<", "EXAMPLE", "example", "itest-pg", "0011", "11*", "22*", "33*", "44*")
|
||||
|
||||
|
||||
def _iter_files():
|
||||
for d in SCAN_DIRS:
|
||||
base = REPO / d
|
||||
if not base.exists():
|
||||
continue
|
||||
for pat in SCAN_GLOBS:
|
||||
for f in base.rglob(pat):
|
||||
if ".git" in f.parts or "node_modules" in f.parts:
|
||||
continue
|
||||
yield f
|
||||
|
||||
|
||||
def test_no_dsn_with_credentials():
|
||||
hits = []
|
||||
for f in _iter_files():
|
||||
for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1):
|
||||
if DSN.search(line) and not any(a in line for a in ALLOW_SUBSTR):
|
||||
hits.append(f"{f}:{i}")
|
||||
assert not hits, f"DSN con credencial literal en: {hits}"
|
||||
|
||||
|
||||
def test_no_nsec_in_versioned_files():
|
||||
hits = []
|
||||
for f in _iter_files():
|
||||
if "/tests/" in str(f):
|
||||
continue
|
||||
for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1):
|
||||
if NSEC.search(line):
|
||||
hits.append(f"{f}:{i}")
|
||||
assert not hits, f"nsec en fichero versionado: {hits}"
|
||||
|
||||
|
||||
def test_no_raw_hex_tokens_outside_tests():
|
||||
"""Tokens hex de 64 chars fuera de tests/ y del conftest (las claves de
|
||||
test viven solo en tests/). El outbox no debe contener secretos."""
|
||||
hits = []
|
||||
for f in _iter_files():
|
||||
if "/tests/" in str(f) or f.name == "cayce-identity.md":
|
||||
continue
|
||||
for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1):
|
||||
if HEXTOKEN.search(line):
|
||||
hits.append(f"{f}:{i}")
|
||||
assert not hits, f"posible token hex versionado: {hits}"
|
||||
Reference in New Issue
Block a user