Skip to content

Security: agustincf/Arcade1v1

Security

SECURITY.md

Repaso de seguridad — Arcade1v1 (Fase 6)

Fecha: 2026-06-21 (1ª ronda) · 2026-06-26 (2ª ronda) · 2026-07-02 (3ª ronda — preparación mainnet, ver abajo) · Estado actualizado: v3.0.1 en testnet (no opera con dinero real).

Nota de mantenimiento (2026-07-11): los hallazgos y la verificación de la tercera ronda siguen fechados el 2026-07-02. Este documento ya refleja las entregas posteriores de producto cuando afectan su estado operativo; no afirma direcciones, claves ni un despliegue público porque viven fuera de Git.

Nota de mantenimiento (2026-07-14): la 3.0.1 sumó tres arreglos en la superficie de acciones firmadas (deploy/pausa/borrado de agentes, editar perfil): clasificación correcta de fallos de firma (apps/web/app/lib/errors.ts, con tests) para no mostrar "no pudimos conectar con el servidor" cuando en realidad el árbitro rechazó el pedido con un motivo real; aviso proactivo del tope de 3 agentes por wallet antes de intentar la acción; y useEnsureChain (apps/web/app/lib/wallet.tsx), que pide cambiar de red antes de firmar en vez de fallar con el error críptico de wagmi "Chain not configured". Son mejoras de confiabilidad y claridad de la UX de firma — no cierran ningún hallazgo de los listados abajo (ninguno trataba mensajes de error engañosos ni el cambio de red como vulnerabilidad); el modelo de amenazas y los hallazgos de esta ronda siguen vigentes sin cambios.

Nota de mantenimiento (2026-08-09): el contrato SÍ cambió desde esta ronda. La cuarta ronda (2026-07-15) publicó v3.3.1 y v3.4.0; esta última cambió Escrow1v1 y su redeploy ya se ejecutó y verificó en Base Sepolia — escrow 0xF6B4bd37d4571B23a707A3C128fcA1a4714BeecB. Agregó el asiento firmado por el árbitro (Seat(matchId,player), que open/join exigen: ata al rival sin gasto de gas del árbitro) y la gracia de 30 minutos antes de refundExpired (REFUND_GRACE = 1800), que cierra el front-run del perdedor. Donde este documento diga "contrato sin cambios a propósito", leer: sin cambios hasta v3.3.1.

Una quinta ronda (2026-08-09) volvió a auditar todo el repo. Lo que sigue abierto y no está listado abajo, por orden de gravedad: (1) los verificadores de replay no acotan las acciones por tick — una partida entera de Tetris entra en ticks: 1; (2) la semilla se entrega antes de que exista rival y el replay no lleva reloj real, así que se puede optimizar la corrida offline; (3) el asiento firmado ata quién entra pero no con qué condiciones (stake y plazos los elige quien abre); (4) el árbitro nunca lee el escrow, así que empareja y firma sin saber si alguien depositó.


Actualización 2026-07-02 — tercera ronda (auditoría completa pre-mainnet)

Pasada completa sobre contrato, árbitro, web, MCP y SDKs. Resuelto en esta ronda:

🔴 CRÍTICO — RESUELTO: el rival podía espiar tu puntaje antes de jugar

GET /match/:id devolvía los scores de la partida a cualquiera, también antes del cierre. El segundo jugador consultaba el puntaje del primero y jugaba sabiendo EXACTAMENTE cuánto superar (o directamente no depositaba/jugaba si no le convenía) → ventaja desleal decisiva con plata en juego. Arreglo: hasta que la partida se decide, cada jugador ve solo su propio puntaje (nuevo campo rivalSubmitted avisa que el rival ya jugó, sin revelar cuánto); el detalle completo aparece recién al liquidar. Cubierto en selftest.

🟠 Altos — RESUELTOS

  • Emparejar sin autenticación. Cualquiera podía encolar direcciones AJENAS (suplantación) o llenar la cola de rivales fantasma que nunca depositan (el rival real deposita, espera y pierde tiempo y gas). Arreglo: /matchmake ahora exige (en producción, mismo criterio REQUIRE_AUTH que el envío de puntaje) una firma de la wallet sobre matchmakeAuthMessage(game, stake, address, ts) con ventana anti-replay de 10 min. Web, agent-sdk y MCP ya firman solos. Cubierto en selftest (válida sí / ajena no / vencida no).
  • approve infinito en la web. El flujo de depósito aprobaba maxUint256 hacia el escrow: un bug del contrato podía drenar todo el USDC de la wallet. Arreglo: se aprueba el monto exacto de la apuesta, cada vez.
  • join a ciegas. P2 depositaba sin verificar la partida on-chain. Un P1 malicioso podía abrirla por su cuenta con playDeadline lejano (años) y dejar el depósito de P2 atrapado hasta entonces. Arreglo: antes de unirse, la web lee la partida del contrato y verifica estado/monto/plazos normales; ante cualquier anomalía no deposita. Defensa extra: el barrendero del árbitro (abajo) cancela on-chain las partidas emparejadas sin resultado (reembolso).
  • Partidas eternas en memoria/disco. Las partidas nunca se purgaban (fuga de memoria) y una emparejada sin resultado quedaba colgada para siempre. Arreglo: barrendero cada 60s — waiters vencidos se descartan, terminadas viejas se purgan, y una emparejada sin resultado al vencer la ventana de envío (2h, SUBMIT_WINDOW_MS) se marca expirada y se cancela on-chain (reembolso a ambos). Además los envíos tardíos o a partidas ya decididas se rechazan. Cubierto en selftest.

🟡 Medios/bajos — RESUELTOS

  • Mesas sin validar en el árbitro (NaN/negativos/montos arbitrarios creaban colas basura persistidas): ahora solo se aceptan las mesas permitidas (STAKES_ALLOWED, default 1/2/5/10 — las del contrato). Cubierto en selftest.
  • Direcciones sensibles a mayúsculas: 0xAbC… y 0xabc… eran dos jugadores distintos (doble ELO, errores de reenvío). Ahora se normalizan a minúsculas.
  • Semilla con Math.random (predecible): ahora crypto.randomInt (CSPRNG).
  • Rate-limit con fuga de memoria (una entrada por IP para siempre): limpieza periódica. Persistencia que escribía TODO el archivo por request (bloqueo del event loop): ahora con debounce + flush en el apagado (SIGTERM).
  • Empates quemaban gas si la partida no era cancelable on-chain: el árbitro ahora simula cancelMatch antes de mandar la transacción.
  • Config de producción: la guarda ahora también exige RPC_URL (sin nodo no hay reembolso automático de empates/vencidas). Cubierto en selftest.
  • Web: cabeceras de seguridad (anti-clickjacking frame-ancestors 'none', nosniff, Referrer-Policy, Permissions-Policy); el botón "vs Bot" ya no aparece en producción; los contadores sintéticos de actividad ("N jugadores en línea/esperando" — datos de demo) no se muestran en mainnet (inventar actividad a gente que apuesta es engañarla); RPC propio configurable (NEXT_PUBLIC_RPC_URL) para no depender del público en producción.

Verificación de esta ronda

npm run check completo (tipos web+server, lint, formato, 36 tests, selftest con los casos nuevos) + forge test 9/9 + check-integration.sh (digest EIP-712) + check-payment-e2e.sh (pago y empate reales en cadena local con el árbitro modificado) + next build de producción. Todo en verde.

Sigue pendiente (sin cambios en esta ronda)

  • Contrato sin cambios a propósito en esta ronda (decisión sostenida en su momento: no tocar Solidity sin auditoría humana). Se levantó en v3.4.0, ya desplegada — ver la nota de mantenimiento del 2026-08-09 arriba. La auditoría externa profesional sigue pendiente.
  • Llave del árbitro en KMS/HSM y owner multisig/hardware — operacional.
  • Lo legal (licencias/KYC/edad/geobloqueo) — decisión del dueño del proyecto.
  • Escala horizontal: desde v2.1 el árbitro puede persistir en Redis externo (el fallback local sigue siendo válido para una sola instancia). En producción debe configurarse Redis; coordinar colas y rate-limit entre múltiples instancias sigue siendo un trabajo operativo pendiente.

Este documento es el resultado de revisar el contrato (packages/contracts), el backend árbitro (apps/server) y la arquitectura completa. La idea es ser honestos sobre lo que falta antes de pensar en dinero real.


Actualización 2026-06-26 — segunda ronda (preparación mainnet)

Nueva pasada de revisión enfocada en el flujo de dinero. Resumen de lo encontrado y resuelto en esta ronda (detalle en cada hallazgo más abajo):

🔴 CRÍTICO — RESUELTO: replay con semilla ajena

El árbitro re-jugaba el replay usando la semilla que mandaba el cliente, sin compararla con la semilla real de la partida. Un jugador podía ignorar la semilla justa, probar muchas semillas offline y mandar una favorable (con un puntaje alto que "coincidía" al re-jugar esa misma semilla) → ganaba con dinero real de forma desleal, y afectaba a los 6 juegos. Arreglo: submitScore ahora exige replay.seed === match.seed y fuerza la semilla real al re-jugar (el árbitro manda sobre el azar, nunca el cliente). Cubierto en selftest ("replay con semilla ajena RECHAZADO").

🟠 Altos/medios — RESUELTOS

  • Un intento por jugador. El puntaje se congela en el primer envío válido. Antes, el primero en enviar podía reintentar hasta sacar su mejor marca (ventaja desleal sobre el rival, que al enviar cierra la partida). Cubierto en selftest.
  • Guarda de configuración de producción (apps/server/src/config-guard.ts). En producción con escrow activo, el servidor no arranca si falta CHAIN_ID, ARBITER_PRIVATE_KEY o ALLOWED_ORIGIN. Sin esto, CHAIN_ID caía por defecto en testnet (84532) y las firmas del árbitro no servían para cobrar en mainnet. Cubierto en selftest.
  • Anti-DoS de replay. Re-jugar es O(ticks)/O(eventos): un replay chiquito con ticks: 1e9 (que entra en el límite de 256 kb) obligaba a iterar mil millones de veces. Ahora se topa ticks/eventos antes de re-jugar (replayTooLong). Cubierto en selftest.
  • trust proxy. req.ip usa X-Forwarded-For detrás de un reverse proxy, así el rate-limit no agrupa a todos los clientes bajo la IP del proxy.

Pendientes de esta ronda (decisión de auditoría / operacional)

No se tocaron a propósito; cambiar el contrato (dinero real) sin auditoría humana ni tests locales de Foundry sería un riesgo mayor que el que resuelven:

  • Pausa de emergencia acotada en el contrato. Si se agrega, debe pausar solo las ENTRADAS (open/join) y nunca las SALIDAS (settle, refund*), para poder frenar nuevos depósitos sin atrapar fondos ya custodiados. Decidir e implementar dentro de la auditoría humana (con sus tests).
  • Timelock / multisig del owner y resguardo de la llave del árbitro (KMS/HSM o firma múltiple). Son medidas operacionales/de despliegue, no código de este repo. Ver hallazgos 7 y 13.

Modelo de confianza (quién puede hacer qué)

  • Jugadores: depositan USDC y juegan. No pueden sacar fondos salvo según las reglas (premio, reembolso).
  • Árbitro (backend): decide quién ganó y firma el resultado. Es el punto central de confianza: si su llave se filtra, puede decidir partidas (aunque el ganador siempre debe ser uno de los dos jugadores).
  • Dueño (owner del contrato): configura árbitro, comisión (tope 20%), wallet de comisión y mesas permitidas. No puede enviar fondos a una dirección arbitraria.
  • El contrato: custodia el pozo y solo lo mueve según las reglas verificadas.

Lo que el contrato YA garantiza (lo bueno) ✅

  • Nunca paga más de lo depositado (premio = pozo solo si los dos depositaron).
  • El premio va solo a un jugador real (p1 o p2); la comisión está topeada al 20% y el dueño no puede subirla más.
  • Protección anti-reentrada y patrón "estado antes de transferir".
  • Firmas EIP-712 atadas a la red (chainId) y al contrato → no se pueden reusar en otra red/contrato; matchId único → no se puede liquidar dos veces.
  • Reembolsos por: no llenarse a tiempo, vencimiento del plazo de juego (1h) o cancelación del árbitro/dueño.
  • 9/9 pruebas automáticas pasan.

Hallazgos (priorizados)

🔴 Críticos — bloquean el dinero real

  1. Puntaje sin verificar (anti-trampa).RESUELTO en los 6 juegos (2048, Tetris, Flappy, Carrera, Snake y Space Invaders), con default-deny: el árbitro tiene un registro de verificadores y rechaza cualquier juego que no sepa verificar (nunca confía en un puntaje sin re-jugar el replay). Motor compartido web/servidor; los de tiempo real corren con paso de tiempo fijo (por ticks) y graban las entradas con su tick. El servidor re-simula el replay y rechaza cualquier puntaje inventado — verificado en selftest (legítimo aceptado e inventado rechazado en los 6, + juego desconocido rechazado). (Los juegos nuevos deben sumar su verificador al registro.)

  2. El flujo de dinero on-chain está probado de punta a punta con el backend real. 🟡 Modelo asincrónico open/join: el árbitro ya no crea la partida ni adelanta el stake — cada jugador deposita por su cuenta (uno abre, el otro se une) y emparejar es solo orden de llegada. El árbitro sí necesita gas para cancelaciones y reembolsos automáticos. El ciclo completo está verificado en cadena local con el árbitro de verdad (ver packages/contracts/check-payment-e2e.sh, + tests 9/9 + selftest + check-integration.sh):

    • Victoria: emparejar → p1 abre depositando → p2 se une depositando → juegan → el árbitro firma → el contrato paga al ganador (8.5) + comisión (1.5) y el escrow queda en 0. ✅
    • Empate / sin resultado a tiempo / sin rival: reembolso on-chain (cancelMatch en empate; refundExpired / refundUnfunded por vencimiento). ✅

    La UI de pago está enchufada a la pantalla de partida (match/page.tsx): con contrato configurado pide conectar wallet → APROBAR el allowance → emparejar → ABRIR/UNIRSE (depositar), y al ganar muestra COBRAR (settle con la firma del árbitro). Además, la página /recover deja reclamar el reembolso si no apareció rival o la partida quedó sin resultado a tiempo. Queda dormida mientras no haya NEXT_PUBLIC_ESCROW_ADDRESS (el modo de prueba no cambia). Las direcciones y secretos de un despliegue público no se versionan en este repositorio: antes de habilitar stakes hay que verificar esa configuración y completar una prueba pública en Base Sepolia con usuarios reales.

  3. Autenticación en el backend.RESUELTO. El jugador (y los agentes) firman su envío con la wallet; el árbitro verifica que la firma recupere su dirección (verificado en selftest: firma válida aceptada, firma que no corresponde rechazada). Seguro por defecto: en producción (NODE_ENV=production) la firma es obligatoria sin tener que recordar REQUIRE_AUTH (hay que poner REQUIRE_AUTH=false para desactivarla a propósito); en dev queda opcional para permitir invitados de prueba. Sin esto, alguien podría mandar un puntaje a nombre del rival para hacerlo perder.

  4. Legal / regulatorio. Apuestas con dinero real = licencias, KYC/AML, verificación de edad y restricciones por país. Sin esto no se puede operar legalmente. (Bloqueante no técnico, el más importante.)

🟠 Altos

  1. Endpoint /bot de prueba.RESUELTO. Queda apagado en producción (NODE_ENV=production, salvo ENABLE_TEST_BOT=true).
  2. Fallback "offline" simulado en el frontend.RESUELTO. En producción nunca se muestra un rival/resultado inventado: si el árbitro no responde, se muestra un error (la simulación queda solo en desarrollo).
  3. Llave del árbitro = único punto de confianza. Guardarla en un KMS/HSM, con mínimos privilegios; considerar un árbitro multi-firma.
  4. Estado y escala del árbitro. 🟡 Desde v2.1 el árbitro usa Redis externo cuando se configuran sus credenciales; así ELO, partidas y agentes sobreviven deploys. El fallback de archivos locales conserva el modo de una instancia. Para una operación con varias instancias aún hay que configurar el store compartido y coordinar colas/rate-limit entre nodos.
  5. Auditoría externa del contrato por un tercero profesional antes de dinero real. 🟡 Parcial: se corrió análisis estático con Slither (0.11.5, filtrando lib/+test/): sin hallazgos críticos/altos/medios. Solo 4 avisos informativos de block.timestamp en los plazos —irrelevantes a escala de 1 hora, aceptados— y 2 de eventos de admin sin indexed que ya se corrigieron (ArbiterUpdated/PlatformWalletUpdated). El análisis automático no reemplaza una auditoría humana profesional, que sigue pendiente.

🟡 Medios

  1. Rate limiting en el backend. ✅ HECHO — límite por IP (120 pedidos cada 10s) que devuelve 429. Ajustable.
  2. Semilla por Math.random.Endurecido (2026-06-26): el árbitro ahora exige y fuerza la semilla real de la partida al re-jugar (antes aceptaba la semilla del cliente → ver el crítico de la 2ª ronda, arriba). Sirve para que sea igual para ambos jugadores. Mejora futura: un esquema commit-reveal sería aún más robusto contra la predicción de la semilla (Math.random es predecible).
  3. Empate dispara el reembolso on-chain.HECHO — al detectar empate el árbitro llama cancelMatch y el contrato reembolsa a ambos (verificado e2e en cadena local).
  4. Poderes del dueño. Mucha confianza concentrada. Considerar timelock / multisig para el owner.

🟢 Bajos

  1. Sin pausa de emergencia en el contrato (es inmutable; bueno para la confianza, pero no hay "freno" si algo sale mal). Recomendación para la auditoría: una pausa acotada que frene solo las ENTRADAS (open/join) y nunca las SALIDAS (settle/refund*), para no atrapar fondos custodiados. No se agregó en la 2ª ronda para no expandir la superficie del contrato sin auditoría humana ni tests de Foundry (no instalado en el entorno de desarrollo).
  2. CORS — ✅ configurable con ALLOWED_ORIGIN (en producción se restringe a tu dominio; en dev queda abierto).
  3. HTTPS obligatorio en producción (en local es OK sin él).

Checklist "antes de pensar en dinero real"

  • Anti-trampa (verificación por replay) — crítico — hecho en los 6 juegos (2048, Tetris, Flappy, Carrera, Snake, Space Invaders) con default-deny (un juego sin verificador se rechaza). Los juegos nuevos deben seguir el patrón.
  • Replay atado a la semilla de la partida (anti-trampa "semilla ajena") — crítico — hecho (2ª ronda): el árbitro exige y fuerza la semilla real.
  • Un intento por jugador · guarda de config de prod · anti-DoS de replay · trust proxyalto/medio — hechos (2ª ronda; ver actualización arriba).
  • Conectar el flujo on-chain real (depósito USDC open/join + settle + reembolso en empate y por vencimiento) — crítico — implementado y verificado e2e en cadena local. Antes de activar stakes hay que verificar las direcciones y secretos del entorno y completar una prueba pública en testnet.
  • Autenticación de jugadores (firmar los envíos con la wallet) — crítico — hecho y obligatorio por defecto en producción (opt-out explícito con REQUIRE_AUTH=false)
  • Asesoría legal + licencias + KYC/AML + edad + geobloqueocrítico (legal)
  • /bot y el fallback simulado están bloqueados en producción — alto
  • Proteger la llave del árbitro (KMS/HSM, multisig) — alto
  • [~] Persistencia y recuperación del backend — alto — implementadas con Redis externo; falta confirmar su configuración en cada entorno de producción y resolver la coordinación multi-instancia.
  • [~] Auditoría externa del contrato — alto — análisis estático (Slither) hecho y limpio (sin críticos/altos/medios); falta la auditoría humana profesional
  • Rate limiting — medio — configurado en el árbitro.
  • HTTPS y monitoreo operativo (RPC propio + saldo de gas) — medio
  • Pruebas de extremo a extremo en testnet con varios usuarios reales — medio

Conclusión: la base está sólida y el contrato es seguro en lo que cubre, pero NO se debe activar dinero real hasta cerrar al menos los 4 puntos críticos (con la parte legal a la cabeza).

There aren't any published security advisories