Add TLS + certificate pinning for the peer link
The co-op link was authenticated (HMAC token, never sent over the wire) but not encrypted. The hub now generates a self-signed cert on first run; the peer pins its exact fingerprint (no CA involved — there isn't one for a Tailscale/LAN address), delivered via a single invite-code paste that also carries the token, replacing today's separate IP+token copy. The peer link moves to its own TLS-only port (peer_listen, 47823) so the plain overlay/panel port (47822, OBS-facing) never needs to be exposed alongside it — today, opening the overlay port to a remote partner also exposes /deaths and the panel to anyone. Mandatory pinning, no insecure fallback: a half-configured peer (some but not all of hub/token/fingerprint, or a broken invite) fails loudly at startup rather than connecting unpinned. An unconfigured peer still runs fine as a local-only overlay, same as before. New: tlscert.go (cert generation/persistence), pin.go (fingerprint pinning), invite.go (invite-code encode/decode, host auto-detection), each with tests. main.go/config.go/duo.go/ws.go carry the wiring for this — the dual listener, new config keys, and the TLS-aware WebSocket dial — and were rewritten in English in the process, per the project's new English-only code convention (see CLAUDE.md).
This commit is contained in:
1 parent
e1f7e6f529
commit
e9fe10f0f7
12 files changed
+1198
-465
No files matched your search
@@ -19,16 +19,17 @@ error del código: es que `main.go` e `i18n.go` solo compilan para Windows.
|
||||
## Idioma del código
|
||||
|
||||
El repo va a publicarse en GitHub. **Identificadores, comentarios y
|
||||
mensajes de log/consola van en inglés** — código nuevo se escribe
|
||||
directamente en inglés, sin excepción. `names.go` y `names_test.go` son
|
||||
el primer archivo escrito bajo esta regla; úsenlo de referencia.
|
||||
mensajes de log/consola van en inglés.** Ya se hizo la pasada completa:
|
||||
todo `.go` (identificadores, comentarios, logs) y los comentarios de
|
||||
`overlay.html` están en inglés.
|
||||
|
||||
El resto del código (`main.go`, `i18n.go`, `counter.go`, `totals.go`,
|
||||
`config.go`, `duo.go`, `ws.go`, `auth.go`, comentarios de `overlay.html`)
|
||||
todavía está en español — ver el pendiente de traducción más abajo. Esto
|
||||
**no** afecta a los textos que ve el usuario final en el overlay/panel
|
||||
(`locales/*.json`, `overlay.html`): esos siguen soportando español e
|
||||
inglés vía el sistema de i18n existente, elegido por idioma del sistema.
|
||||
Esto **no** afecta a los textos que ve el usuario final en el
|
||||
overlay/panel (`locales/*.json`, `overlay.html`): esos siguen soportando
|
||||
español e inglés vía el sistema de i18n existente (`i18n.go`), elegido
|
||||
por idioma del sistema. Son cosas distintas a propósito: el código es
|
||||
para quien lo lee en GitHub, el overlay es para quien lo mira en el
|
||||
stream — y por eso el inglés del código no absorbió el sistema de
|
||||
idiomas del overlay, ni al revés.
|
||||
|
||||
## Estructura
|
||||
|
||||
@@ -42,12 +43,15 @@ inglés vía el sistema de i18n existente, elegido por idioma del sistema.
|
||||
| `config.go` | Parser TOML propio + `config.toml` | portable |
|
||||
| `duo.go` | Modo co-op: registro de peers, hub y peer | portable |
|
||||
| `ws.go` | WebSocket hecho a mano (RFC 6455, subconjunto) | portable |
|
||||
| `tlscert.go` | Certificado TLS autofirmado del hub, generación y persistencia | portable |
|
||||
| `pin.go` | Certificate pinning: fingerprint, `tls.Config` del hub y del peer | portable |
|
||||
| `invite.go` | Código de invitación (encode/decode) y adivinar el host | portable |
|
||||
| `overlay.html` | Overlay e interfaz, embebido con `go:embed` | — |
|
||||
|
||||
`counter.go` está separado de `main.go` **a propósito**: es donde vivieron
|
||||
los tres bugs de conteo, y separarlo es lo que permite testearlo sin una PC
|
||||
con el juego abierto. Si agregás reglas de conteo, van ahí, con test.
|
||||
`names.go` sigue el mismo principio para `looksLikeName`.
|
||||
`names.go`, `tlscert.go`, `pin.go` e `invite.go` siguen el mismo principio.
|
||||
|
||||
## Offsets de memoria
|
||||
|
||||
@@ -79,7 +83,48 @@ Cheat Engine.
|
||||
|
||||
El transporte **no se asume confiable**: puede ser Tailscale, ZeroTier,
|
||||
WireGuard, o un puerto abierto directo a internet. La conexión se defiende
|
||||
sola (`auth.go`).
|
||||
sola, en dos capas independientes: TLS cifra y autentica al HUB frente al
|
||||
peer (`tlscert.go`, `pin.go`), y el token autentica al PEER frente al hub
|
||||
(`auth.go`). Ninguna reemplaza a la otra.
|
||||
|
||||
**Dos puertos, no uno**, a propósito — no se puede servir HTTP y HTTPS en
|
||||
el mismo puerto, y mezclarlos igual sería mala idea:
|
||||
|
||||
| Puerto | Config | Sirve | Protocolo | Audiencia |
|
||||
|---|---|---|---|---|
|
||||
| 47822 | `listen` | overlay, panel, `/deaths`, `/strings.json` | HTTP plano | OBS y el navegador, local o LAN |
|
||||
| 47823 | `peer_listen` | sólo `/ws` | TLS 1.3 + pinning | el peer, cruzando redes ajenas |
|
||||
|
||||
Un certificado autofirmado haría que OBS y el navegador tiren advertencias
|
||||
o no carguen; con los puertos separados el overlay nunca ve un
|
||||
certificado. Y el beneficio mayor: quien abre el 47823 a internet para que
|
||||
entre su compañero expone únicamente el endpoint autenticado+cifrado — el
|
||||
panel y `/deaths` (que no piden nada y responden con
|
||||
`Access-Control-Allow-Origin: *`) se quedan en 47822, en casa.
|
||||
|
||||
**TLS: certificado propio + pinning, no autoridad certificante.** No hay
|
||||
dominio ni CA posible para una IP de Tailscale o LAN, así que no se
|
||||
intenta: el hub genera un certificado autofirmado (`ed25519` +
|
||||
`crypto/x509`) la primera vez y lo guarda (`hub-cert.pem`/`hub-key.pem`,
|
||||
gitignored, junto al token). El peer se conecta con `InsecureSkipVerify:
|
||||
true` **y** un `VerifyPeerCertificate` que exige que la huella SHA-256 sea
|
||||
exactamente la esperada (`pin.go`) — el nombre del flag asusta y en una
|
||||
revisión parece un error: no lo es. Apaga la validación por CA/dominio,
|
||||
que acá no aplica, y la reemplaza por *pinning*, que para este caso es
|
||||
**más** estricto que la validación normal. Regenerar el certificado
|
||||
(borrar los dos archivos) cambia la huella: cualquier invitación vieja
|
||||
deja de servir.
|
||||
|
||||
**Invitación de un solo paste.** El hub imprime un código — base64 de
|
||||
`{host, puerto, huella, token}` (`invite.go`) — que el peer pega como
|
||||
`invite = "..."` en su `config.toml`. Reemplaza copiar la IP y el token
|
||||
por separado, sin agregar un paso. El `host` se adivina solo
|
||||
(`candidateIPv4s`/`pickBestHost`, prefiere una IP de Tailscale), pero es
|
||||
sólo una adivinanza: `hub =` en el config del peer pisa ese campo si hace
|
||||
falta. También existe la ruta manual (`hub`+`token`+`fingerprint`, los
|
||||
tres juntos) para quien prefiera no pegar el blob; nunca hay una conexión
|
||||
sin fijar la huella — un peer a medio configurar falla fuerte al arrancar
|
||||
en vez de conectarse sin pinning.
|
||||
|
||||
- El token es **obligatorio**. Lo genera el programa (`token.txt`, 128
|
||||
bits); `token` en `config.toml` lo pisa si alguien quiere elegirlo.
|
||||
@@ -93,14 +138,11 @@ sola (`auth.go`).
|
||||
- El `id` que vale es el de la conexión autenticada, no el que declare
|
||||
cada mensaje.
|
||||
|
||||
**Lo que esto NO hace: cifrar.** Los mensajes (nombre y número de muertes)
|
||||
viajan en claro, y un atacante activo en el medio podría alterarlos. El
|
||||
token garantiza que nadie **inyecte** datos falsos, no que nadie los lea.
|
||||
Está pendiente cifrarlo (ver Pendientes).
|
||||
|
||||
Los tests de esto están en `auth_test.go` y cubren: token correcto, token
|
||||
Los tests de esto están en `auth_test.go` (token correcto, token
|
||||
equivocado, mensajes sin autenticar, que el token no aparezca en el
|
||||
tráfico, y que una respuesta vieja no se pueda repetir.
|
||||
tráfico, que una respuesta vieja no se pueda repetir), `pin_test.go`
|
||||
(handshake TLS completo: acepta la huella correcta, rechaza cualquier
|
||||
otra) e `invite_test.go` (ida y vuelta del código, entradas rotas).
|
||||
|
||||
## Reglas que salieron de bugs reales
|
||||
|
||||
@@ -149,10 +191,10 @@ No son preferencias de estilo. Cada una costó un bug en producción.
|
||||
|
||||
## Pendientes
|
||||
|
||||
- Pasada completa a inglés: identificadores, comentarios, mensajes de
|
||||
log/consola y el README (hoy todo está en español salvo `names.go` y
|
||||
`names_test.go`, ver "Idioma del código" arriba). Conviene que sea un
|
||||
commit aparte del resto, dado el volumen del diff.
|
||||
- No hay `README.md` todavía. Escribir uno en inglés (instalación, modo
|
||||
hub/peer, capturas) es lo único que falta del pendiente de idioma — el
|
||||
código ya está en inglés de punta a punta, ver "Idioma del código"
|
||||
arriba.
|
||||
- La identificación por nombre (respaldo cuando no se lee el slot) mezcla
|
||||
personajes homónimos. Documentado, no resuelto.
|
||||
- **Compilar y correr en Linux (Proton).** Mucha gente juega Elden Ring
|
||||
@@ -204,8 +246,8 @@ No son preferencias de estilo. Cada una costó un bug en producción.
|
||||
`GetUserDefaultLocaleName`.
|
||||
|
||||
No hay que tocar: los offsets, las firmas, `counter.go`, `names.go`,
|
||||
`totals.go`, `duo.go`, `ws.go`, `config.go` ni el overlay. Ya son
|
||||
portables.
|
||||
`totals.go`, `duo.go`, `ws.go`, `tlscert.go`, `pin.go`, `invite.go`,
|
||||
`config.go` ni el overlay. Ya son portables.
|
||||
|
||||
**Modo sólo-hub, de regalo.** Un hub que no lee memoria —que sólo junta
|
||||
lo de los peers y sirve el overlay— no necesita `setcap` ni permiso
|
||||
@@ -217,60 +259,5 @@ No son preferencias de estilo. Cada una costó un bug en producción.
|
||||
[cheat-engine-linux](https://github.com/wleeaf/cheat-engine-linux),
|
||||
[un trainer para D2R en Linux paso a paso](https://axiom0x0.sh/posts/d2r-memory-trainer-part2/).
|
||||
|
||||
- **Cifrar el enlace con TLS (certificado propio + pinning).** Hoy la
|
||||
conexión está autenticada pero no cifrada.
|
||||
|
||||
La forma seria es **TLS 1.3 de la biblioteca estándar**, no un protocolo
|
||||
hecho a mano. El obstáculo aparente es que no hay dominio ni autoridad
|
||||
certificadora posible: nadie emite un certificado para `100.x.y.z`. Pero
|
||||
eso deja de importar al ver que **el problema de confianza ya está
|
||||
resuelto fuera de banda**: los dos jugadores ya se pasan un secreto a
|
||||
mano. Ese mismo canal puede llevar la huella del certificado.
|
||||
|
||||
1. El hub genera un certificado autofirmado en el primer arranque
|
||||
(`crypto/x509` + `ed25519`) y lo guarda junto al token.
|
||||
2. El peer se conecta con `tls.Client` usando `InsecureSkipVerify: true`
|
||||
**y** un `VerifyPeerCertificate` que exige que la huella SHA-256 sea
|
||||
exactamente la esperada. El nombre del flag asusta y en una revisión
|
||||
parece un error: no lo es. Apaga la validación por CA y nombre de
|
||||
dominio, que acá no aplican, y la reemplaza por *pinning*, que para
|
||||
este caso es **más** estricto que la validación normal.
|
||||
3. Un solo **código de invitación** que el hub imprime y el peer pega:
|
||||
base64 de `{host, puerto, huella, token}`. Como ya tenían que copiar
|
||||
algo, esto no agrega ni un paso, y a cambio la conexión pasa a estar
|
||||
cifrada de verdad. Para quien lo usa, el tema desaparece.
|
||||
|
||||
Con eso se obtiene TLS 1.3 real: forward secrecy, AEAD y un handshake
|
||||
revisado por medio mundo, sin dependencias externas y sin que nadie
|
||||
tenga que entender nada.
|
||||
|
||||
**Dos puertos, no uno.** No se puede servir HTTP y HTTPS en el mismo
|
||||
puerto, así que la separación no es un detalle de implementación sino
|
||||
parte del diseño:
|
||||
|
||||
| Puerto | Sirve | Protocolo | Audiencia |
|
||||
|---|---|---|---|
|
||||
| 47822 | overlay, panel, `/deaths`, `/strings.json` | HTTP plano | OBS y el navegador, local o LAN |
|
||||
| 47823 | sólo `/ws` | TLS + pinning | el peer, cruzando redes ajenas |
|
||||
|
||||
Esto resuelve lo obvio — un certificado autofirmado hace que OBS y el
|
||||
navegador tiren advertencias o no carguen, y con puertos separados el
|
||||
overlay nunca ve un certificado — pero el beneficio mayor es otro y
|
||||
conviene no perderlo de vista:
|
||||
|
||||
**Achica lo que queda expuesto.** Hoy, quien abra el 47822 a internet
|
||||
para que entre su compañero está publicando también el panel y
|
||||
`/deaths`, que no piden nada y encima responden con
|
||||
`Access-Control-Allow-Origin: *`: cualquiera puede leer los nombres de
|
||||
los personajes y los contadores. Con la separación, el único puerto que
|
||||
hace falta exponer sirve exclusivamente el endpoint autenticado, y el
|
||||
del overlay se queda en casa. Si se implementa TLS, esta parte va
|
||||
primero: vale por sí sola aunque el cifrado quede para después.
|
||||
|
||||
Implica una clave nueva en el config (algo como `peer_listen`), y que el
|
||||
código de invitación lleve ese puerto y no el del overlay.
|
||||
|
||||
Detalles: si se regenera el certificado (reinstalación, borrado del
|
||||
archivo) cambia la huella y hay que pasar un código nuevo; documentarlo.
|
||||
Y el README igual debería mencionar que meter todo adentro de una VPN
|
||||
sigue siendo una opción perfectamente válida.
|
||||
- El README debería mencionar que meter todo adentro de una VPN sigue
|
||||
siendo una opción perfectamente válida, TLS+pinning aparte.
|
||||
Reference in new issue
Block a user