Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2abcdee144 | ||
|
|
50c19ebc9f | ||
|
|
a5d58dadd4 | ||
|
|
4280274239 | ||
|
|
7f3e2b5abc | ||
|
|
b9477c6139 | ||
|
|
039c916f55 | ||
|
|
d8c1e845b3 | ||
|
|
fcc19c31e1 | ||
|
|
6f3ca4ed7a | ||
|
|
6fcffb2096 | ||
|
|
ee817a8c9b | ||
|
|
2e97a6b822 | ||
|
|
133e292aab | ||
|
|
e89fea499c | ||
|
|
a6fe438b06 | ||
|
|
f757a01b9b | ||
|
|
2966e79578 | ||
|
|
3816b0426e | ||
|
|
57291a381b | ||
|
|
0333f15be5 | ||
|
|
82b27977eb | ||
|
|
f690e1783b | ||
|
|
2874f7c933 | ||
|
|
46636bdba9 | ||
|
|
dca3758c68 | ||
|
|
d232f0bc0d | ||
|
|
8d0c2620d3 | ||
|
|
d8a35d94cf | ||
|
|
b77d21fd84 | ||
|
|
889ffeb8dd | ||
|
|
f9acd12548 | ||
|
|
b5f0c81b88 | ||
|
|
2950559203 | ||
|
|
d3971de3a8 | ||
|
|
a7c65ed7ec | ||
|
|
b101ca4210 | ||
|
|
aa5838c010 | ||
|
|
788ed3bada | ||
|
|
0110e84c49 | ||
|
|
b3dedc1865 | ||
|
|
43829fb570 | ||
|
|
89766e7fdc | ||
|
|
8f5bb0d2ba | ||
|
|
c2b7c37c1e | ||
|
|
9ee860a78a |
@@ -23,10 +23,3 @@ node_modules/
|
||||
npm-debug.log*
|
||||
txoko-roadmap.md
|
||||
TRASPASO.md
|
||||
PRUEBA-PERITAJE.md
|
||||
HALLAZGOS-PERITAJE.md
|
||||
MEJORAS.md
|
||||
COHERENCIA.md
|
||||
|
||||
GIT.md
|
||||
REPASO.md
|
||||
|
||||
-306
@@ -10,312 +10,6 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
|
||||
- **MENOR** — características nuevas que no rompen lo anterior
|
||||
- **PARCHE** — arreglos de errores
|
||||
|
||||
---
|
||||
## [1.13.0] — 2026-08-08
|
||||
### Añadido
|
||||
- **Nueva comprobación: direcciones que quedan vinculadas entre sí.** Era el
|
||||
hueco de fondo del analizador: medía la privacidad desde el lado de quien
|
||||
*construye* la transacción (¿se ve mi cambio?, ¿pago cifras redondas?) y no
|
||||
desde el lado de los dueños de las monedas gastadas, que es donde el daño es
|
||||
irreversible. Gastar N direcciones distintas a la vez las enlaza para siempre
|
||||
por CIOH, y eso no se decía en ningún sitio. El peso sube con el número de
|
||||
direcciones y no aplica en CoinJoin, donde romper esa vinculación es el
|
||||
objetivo.
|
||||
|
||||
### Corregido
|
||||
- **El explorador mostraba saldo cero en direcciones con mucho historial.** El
|
||||
backend de Mempool sobre Fulcrum devuelve los importes a cero cuando la
|
||||
dirección tiene muchas transacciones, aunque el contador venga bien — y la app
|
||||
los presentaba como ciertos. Una dirección con 492 transacciones aparecía con
|
||||
"Balance: 0,00000000 BTC". Ahora se detecta la inconsistencia (tener
|
||||
transacciones y no haber recibido nada es imposible en la cadena), los
|
||||
importes se muestran como "no disponible" y se explica por qué. El contador de
|
||||
transacciones y el historial, que sí son correctos, se mantienen.
|
||||
- Mismo arreglo en el perfil de dirección del peritaje: antes concluía "no es
|
||||
hot wallet" cuando en realidad no había podido evaluar la señal de flujo.
|
||||
- **El fingerprinting atribuía transacciones a Electrum por AUSENCIA de rasgos**
|
||||
(locktime 0, sin RBF, sequence por defecto). Es justo al revés: Electrum
|
||||
moderno pone locktime a la altura actual y señaliza RBF. Lo que esas señales
|
||||
describen no es un monedero concreto, sino software que no configura nada.
|
||||
Ahora Electrum exige señales positivas y aparece un resultado nuevo, **"sin
|
||||
rasgos distintivos"**, que además no penaliza: no dejar huella es lo contrario
|
||||
de tener una huella identificable.
|
||||
|
||||
---
|
||||
## [1.12.1] — 2026-07-27
|
||||
### Corregido
|
||||
- **El monitor mostraba una CPU por proceso que engañaba.** La columna `%CPU`
|
||||
de `ps aux` no es el consumo actual sino la **media desde que el proceso
|
||||
arrancó** (tiempo de CPU dividido por tiempo de vida). Un proceso que trabajó
|
||||
mucho hace tres días seguía apareciendo alto para siempre, y se leía como si
|
||||
estuviera saturando la máquina. Se detectó porque los porcentajes por proceso
|
||||
no cambiaban NUNCA entre lecturas mientras la CPU global sí variaba.
|
||||
- Ahora se mide de verdad: dos lecturas de `/proc/PID/stat` separadas 500 ms,
|
||||
el mismo método que ya usaba `getCpuUsage` para el total. Las dos esperas
|
||||
corren en paralelo dentro del mismo `Promise.all`, así que no cuesta tiempo.
|
||||
- La interfaz muestra el **porcentaje sobre el total de la máquina**, que es
|
||||
lo que suele querer saberse, con el porcentaje de un núcleo y la media de
|
||||
`ps` en el tooltip para poder comparar con `top`.
|
||||
- Comprobado con carga artificial: el proceso ocupado marcaba 100% de un
|
||||
núcleo (25% de un sistema de 4) mientras `ps` daba 152%, imposible para un
|
||||
proceso de un solo hilo.
|
||||
|
||||
### Cambiado
|
||||
- El UTXO Map pedía las transacciones de contexto con `fetchWithTimeout`
|
||||
directo, saltándose la caché añadida en la 1.11.0. Era el único punto que no
|
||||
la aprovechaba. Ahora pasa por `get()`, así que esas transacciones —ya
|
||||
confirmadas, e inmutables— se guardan toda la sesión.
|
||||
|
||||
### Documentación
|
||||
- **Reescrita la guía de instalación.** `SETUP.md` no era una guía: más de la
|
||||
mitad eran los pasos personales de git y Gitea del autor ("PASO 1 — Crear el
|
||||
repo en Gitea"), que se han movido a un documento privado. Ahora es una guía
|
||||
real, con una comprobación tras cada paso para saber si vas bien sin
|
||||
descubrirlo al final con una pantalla en blanco.
|
||||
- **Corregida la configuración de nginx documentada, que estaba rota.** El
|
||||
README indicaba `alias /var/www/txoko/dashboard.html` — un alias a un
|
||||
ARCHIVO. Desde la 1.11.0, con las librerías en `vendor/` y rutas relativas,
|
||||
eso hace que el navegador no las encuentre y la página quede en blanco sin
|
||||
ningún error visible. Debe apuntar al **directorio**. Es el fallo más común
|
||||
de esta instalación y ahora está avisado en tres sitios, con una comprobación
|
||||
concreta (`curl` mirando el `content-type`, no solo el código 200) para
|
||||
descartarlo.
|
||||
- Los pasos de instalación no mencionaban las librerías del frontend, sin las
|
||||
cuales la aplicación no arranca. Ya están integradas en el orden correcto.
|
||||
- Eliminados del repositorio los datos personales del autor: rutas de su
|
||||
máquina en la documentación y, sobre todo, su nombre de usuario del sistema
|
||||
y su ruta de instalación dentro de `txoko-metrics.service`, que llevaban ahí
|
||||
desde el primer commit. El servicio usa ahora marcadores que hay que ajustar
|
||||
antes de instalarlo.
|
||||
- README: estructura del repositorio actualizada (faltaban `tests/`,
|
||||
`instalar-fuentes.sh` y `FIXTURES.md`) y sección de instalación reducida a un
|
||||
resumen que remite a SETUP.md, para que haya una sola fuente de verdad.
|
||||
- SETUP.md incluye ahora tabla de problemas frecuentes y cómo actualizar.
|
||||
|
||||
---
|
||||
## [1.12.0] — 2026-07-27
|
||||
### Añadido
|
||||
- **Auditoría de PSBT: revisar una transacción ANTES de firmarla.** Hasta ahora
|
||||
toda la app era diagnóstico *a posteriori* — te contaba con detalle lo que ya
|
||||
había pasado y no podías cambiar. Esta es la primera pieza que llega a tiempo.
|
||||
Sustituye al antiguo "validador", que solo comprobaba que el archivo empezara
|
||||
por `psbt` y decía su tamaño.
|
||||
- **Parser completo de BIP174 escrito desde cero**, sin librerías, como el
|
||||
resto del proyecto. Validado contra los cinco vectores inválidos que publica
|
||||
el propio estándar: los rechaza los cinco, cada uno con un mensaje que
|
||||
explica en castellano qué está mal (truncada, sin salidas, ya firmada,
|
||||
sin transacción interna, con claves repetidas).
|
||||
- **Todo el análisis es offline.** Una PSBT bien formada ya lleva dentro los
|
||||
importes y scripts de sus entradas, así que no hace falta consultar el nodo:
|
||||
funciona con el nodo sincronizando, y el nodo ni se entera.
|
||||
- Acepta base64, hexadecimal o abrir el archivo `.psbt` directamente.
|
||||
- **Aviso crítico sobre xpubs incrustados.** Las PSBT suelen llevar dentro las
|
||||
claves públicas maestras de la cartera, y están hechas para compartirse — se
|
||||
mandan por correo o chat para que el resto firme. Quien reciba el archivo
|
||||
puede derivar todas las direcciones, presentes y futuras, y ver el saldo y
|
||||
el historial completos. No puede gastar, pero lo ve todo. Ningún wallet
|
||||
avisa de esto.
|
||||
- Detecta además: cambio identificable por tipo de dirección, importes
|
||||
redondos que delatan cuál es el pago, envíos a la propia cartera, carteras
|
||||
multifirma (con su M-de-N) y PSBTs incompletas sin los importes de entrada.
|
||||
- Cada aviso mantiene el formato del resto de la app — Hecho, Consecuencia y
|
||||
**Qué puedes hacer** —, que aquí cobra sentido literal: todavía estás a
|
||||
tiempo de cambiar la transacción.
|
||||
|
||||
### Corregido
|
||||
- **Revisión de la criptografía watch-only.** Se verificó la derivación completa
|
||||
contra los vectores oficiales de los estándares: RIPEMD-160 (los seis del
|
||||
estándar, incluido el de un millón de caracteres), secp256k1, BIP32 (vectores
|
||||
1 y 2) y bech32/BIP173. **La matemática es correcta y no se tocó.** Los fallos
|
||||
estaban en la validación de la entrada:
|
||||
- **No se comprobaba la suma de verificación del xpub.** Un carácter mal
|
||||
copiado se aceptaba sin protestar y generaba 200 direcciones ajenas: el
|
||||
usuario habría visto su cartera "sin actividad" y se habría quedado
|
||||
tranquilo. Falso negativo silencioso, el mismo patrón que el resto de fallos
|
||||
de esta jornada. Nuevo `decodeBase58Check`.
|
||||
- **No se validaba la longitud** del material decodificado (deben ser 78
|
||||
bytes) ni que la clave pública fuese comprimida (0x02/0x03).
|
||||
- **`tpub` se trataba como mainnet.** La red se detectaba mirando si el texto
|
||||
empezaba por "tb", "u" o "v" — y un `tpub`, el formato más común de testnet,
|
||||
empieza por "t" pero no por "tb". Generaba direcciones `bc1…` a partir de
|
||||
claves de testnet. Ahora se detecta por bytes de versión, que son
|
||||
inequívocos, con las diez variantes (x/y/z/Y/Z y t/u/v/U/V).
|
||||
- **`deriveChildPubkey` aceptaba índices endurecidos**, matemáticamente
|
||||
imposibles desde una clave pública. No era alcanzable desde la interfaz,
|
||||
pero una función criptográfica debe defenderse sola.
|
||||
- Añadida la carpeta `tests/` con las cuatro baterías y su documentación,
|
||||
incluido qué **no** cubren: no sustituyen una auditoría externa.
|
||||
|
||||
---
|
||||
## [1.11.0] — 2026-07-27
|
||||
### Añadido
|
||||
- **Las librerías del frontend se sirven desde tu propio nodo.** React, ReactDOM
|
||||
y Babel se cargaban desde unpkg.com, y las fuentes IBM Plex desde
|
||||
fonts.googleapis.com. Ninguno de los dos veía qué transacciones analizabas —
|
||||
pero ambos recibían tu IP pública, la hora y el referer en cada apertura del
|
||||
dashboard: sabían que usabas Txoko, cuándo y desde dónde. Ese metadato es
|
||||
justo lo que esta herramienta enseña a proteger. Ahora no sale ni una
|
||||
petición fuera de tu red, verificable con
|
||||
`grep -E '(src|href)="https?://' dashboard.html` (no devuelve nada).
|
||||
- Consecuencia práctica: **el dashboard funciona sin conexión a internet**.
|
||||
- Las librerías se descargan una vez durante la instalación y se **verifican
|
||||
por hash** (SHA-256 publicados en SETUP.md).
|
||||
- Versiones fijadas (`react@18.3.1`, `@babel/standalone@7.23.10`) en vez de
|
||||
rangos: antes el CDN decidía qué versión te entregaba y podía cambiar sin
|
||||
aviso.
|
||||
- `instalar-fuentes.sh` automatiza la descarga de IBM Plex (licencia OFL) y
|
||||
genera el CSS. Comprueba cada archivo y aborta si algo falla.
|
||||
- **Caché de respuestas del nodo con TTL y coalescencia** en `useApi`. Las
|
||||
pestañas se montan y desmontan al navegar, así que ir a otra pestaña y volver
|
||||
repetía todas las peticiones. Ahora:
|
||||
- Las **transacciones confirmadas se cachean toda la sesión** — son
|
||||
inmutables, no hay motivo para volver a pedirlas. Analizador, rastro de
|
||||
procedencia y peritaje dejan de pedir las mismas por separado.
|
||||
- Historial de direcciones 60 s, bloques 60 s, mempool y comisiones 20 s,
|
||||
estado del sistema 2 s (para que el monitor siga mostrando datos vivos).
|
||||
- **Coalescencia:** dos componentes que piden lo mismo a la vez generan una
|
||||
sola petición al nodo, no dos. Mismo patrón que ya usaba
|
||||
`system-metrics.js` en el servidor.
|
||||
- Medido: repetir un rastreo forense ya explorado cuesta **cero peticiones**.
|
||||
|
||||
### Cambiado
|
||||
- **Un hit de OFAC ya no penaliza la banda de privacidad.** Era un error
|
||||
conceptual con consecuencias ideológicas: que tus monedas hayan pasado por una
|
||||
dirección sancionada no revela nada más sobre ti — tu privacidad es idéntica
|
||||
antes y después. Lo que cambia es la probabilidad de que un servicio regulado
|
||||
te bloquee un depósito, que es otro eje: censurabilidad, no privacidad.
|
||||
Restar puntos ahí equivalía a dar por buena la idea de "monedas contaminadas",
|
||||
justo la que la fungibilidad de Bitcoin niega. Se sigue informando del riesgo
|
||||
real, ahora como aviso de censura y con el contexto que faltaba: la lista OFAC
|
||||
es una decisión política de un gobierno concreto, no una determinación
|
||||
judicial, y fuera de su jurisdicción solo llega a través de intermediarios que
|
||||
la aplican.
|
||||
- **RBF pasa a informativo, sin penalización.** Es buena práctica —permite
|
||||
desatascar una transacción sin sobrepagar de entrada— y hoy lo activan casi
|
||||
todos los wallets, así que distingue poco. Restar puntos empujaba al usuario
|
||||
hacia una decisión peor para esconder una señal débil.
|
||||
- Checks con inferencia fuerte (`round_numbers`, `unnecessary_input`,
|
||||
`output_type_mismatch`) reescritos con el patrón Hecho / Interpretación /
|
||||
Consecuencia que ya usaban `dust` e `input_type_mixing`. Antes afirmaban de
|
||||
más ("un analista puede identificarlo sin ambigüedad"); ahora explican cuándo
|
||||
la señal falla y por qué son PROBABLE y no CERTEZA.
|
||||
- `timing`: el texto didáctico describía patrones entre varias transacciones
|
||||
cuando el check solo ve una. Además se aclara que la hora mostrada es la del
|
||||
bloque, no la de la firma — entre ambas puede haber horas de mempool.
|
||||
|
||||
### Documentación
|
||||
- README: nueva sección **"Sí, esto es chain analysis"**. El peritaje usa las
|
||||
mismas técnicas que las empresas de vigilancia de cadena y negarlo restaba
|
||||
credibilidad ante quien lee el código. Se explica qué cambia —quién lo
|
||||
ejecuta, sobre qué, dónde se detiene, quién se queda el informe— y el motivo
|
||||
de fondo: la misma herramienta que sigue el rastro de un ladrón demuestra lo
|
||||
fácil que es seguir el tuyo.
|
||||
- El informe forense advierte del coste de denunciar: entregarlo vincula tu
|
||||
identidad legal con esas direcciones de forma permanente, ante una autoridad
|
||||
que puede compartir el expediente con empresas de análisis. Antes se
|
||||
recomendaba denunciar sin mencionar el precio.
|
||||
- Aviso junto a los botones de exportación: el archivo lleva tus direcciones y
|
||||
tu declaración dentro. Nada ha salido de tu red hasta ese punto; a partir de
|
||||
ahí depende de quien lo descarga.
|
||||
- Corregido "un único archivo HTML autocontenido" (aparecía cuatro veces): desde
|
||||
la 1.11.0 las librerías viven en `vendor/`. Se sustituye por una descripción
|
||||
exacta que además dice más — lo que lees en el archivo es lo que se ejecuta,
|
||||
no hay versión compilada que auditar por separado.
|
||||
|
||||
### Corregido
|
||||
- **El informe ya no atribuye al actor direcciones del otro lado de un
|
||||
CoinJoin.** Detectado probando un rastro que atraviesa un Whirlpool real: el
|
||||
informe declaraba *"el rastro se rompe en un CoinJoin — no se puede atribuir
|
||||
con honestidad más allá de este punto"* y, tres líneas más abajo, listaba
|
||||
**10 direcciones atribuidas al actor**, cinco de ellas salidas de esa misma
|
||||
mezcla. Eran direcciones de otros participantes, señaladas en un documento
|
||||
pensado para acompañar una denuncia.
|
||||
- Los dos fundamentos que las colaban fallan precisamente ahí: **CIOH** asume
|
||||
que quien gasta varios inputs juntos los controla, y un CoinJoin existe para
|
||||
romper esa suposición (es la excepción clásica a la heurística); y la
|
||||
**huella de software** sale estable por construcción, porque todos los
|
||||
participantes usan el mismo programa.
|
||||
- `finalizeForensicGraph` excluye ahora las transacciones marcadas como
|
||||
`mixer` del conjunto de direcciones del actor y del union-find, y la
|
||||
atribución por huella salta esos nodos.
|
||||
- Verificado con el mismo caso: de 10 direcciones atribuidas a 0.
|
||||
- **El informe de wallet ya no da un aprobado optimista cuando faltan datos.**
|
||||
Mismo patrón que el fallo del peritaje, en la pieza central del proyecto:
|
||||
`scanWallet` usaba `.catch(()=>[])` en las tres consultas del escaneo, así que
|
||||
una dirección que el nodo no pudo servir quedaba indistinguible de una
|
||||
dirección sin actividad. Sus transacciones no se traían, no contaban para la
|
||||
reutilización, no entraban en el union-find y no aparecían en el historial —
|
||||
y el informe daba su valoración de salud sin mencionar que le faltaban datos.
|
||||
El sesgo iba siempre hacia el optimismo: menos actividad vista es mejor nota.
|
||||
- Las tres consultas usan ahora `getStrict` y registran los fallos en
|
||||
`report.scanErrors`, con la dirección, la rama y en qué fase ocurrió.
|
||||
- Aviso en rojo **antes** de la banda de salud (una valoración leída sin saber
|
||||
que faltan datos es peor que ninguna valoración), y bloque equivalente al
|
||||
principio del export Markdown.
|
||||
|
||||
- **Tampoco atribuye al actor las direcciones de un custodio.** Misma raíz que
|
||||
el fallo anterior, descubierta probando la poda por rama con un depósito real
|
||||
en Bitfinex: el cluster CIOH ya excluía las direcciones de custodio, pero la
|
||||
atribución por huella de software no, así que la hot wallet del exchange
|
||||
acababa listada como "dirección del actor" mientras la conclusión, dos líneas
|
||||
más abajo, la identificaba correctamente como Bitfinex. La dirección donde el
|
||||
ladrón deposita es del exchange, no del ladrón. Ambos criterios están ahora
|
||||
alineados: ni mezclas ni custodios entran por ninguna de las dos vías.
|
||||
Verificado: de 2 direcciones atribuidas a 1 (solo la rama legítima).
|
||||
|
||||
---
|
||||
## [1.10.1] — 2026-07-27
|
||||
### Añadido
|
||||
- **Tope de ramificación en el peritaje (`MAX_FANOUT`, 25 salidas).** Detectado
|
||||
probando el fixture de dust attack: una transacción que reparte a cientos de
|
||||
direcciones hacía que el motor encolara todas sus ramas y, sobre todo, que
|
||||
perfilara cada dirección de salida — 2 peticiones por dirección, así que una
|
||||
tx de 143 salidas suponía ~286 peticiones al nodo antes siquiera de llegar al
|
||||
frontier, y la pestaña se congelaba. Ahora, por encima del tope, la tx se
|
||||
trata como reparto masivo (dust attack, lote de retiradas, airdrop): se corta
|
||||
ANTES de perfilar nada, con `stopReason: "fanOut"` y su propia conclusión en
|
||||
el informe, redactada como límite deliberado y no como inferencia sobre quién
|
||||
controla esas direcciones. Verificado con una tx real de 143 salidas: 2
|
||||
peticiones en total.
|
||||
|
||||
### Corregido
|
||||
- **Peritaje forense: un fallo de consulta ya no se presenta como "rastro
|
||||
completo".** Detectado probando contra el nodo real: cuando el backend
|
||||
devolvía un 503 o agotaba el timeout, la app lo mostraba como
|
||||
*"✓ Rastro completo — no quedan ramas por explorar"* y contaba esa rama como
|
||||
terminal. La causa eran dos capturas silenciosas encadenadas — `get` devolvía
|
||||
`mockData` ante cualquier error y `findSpendingTx` remataba con
|
||||
`.catch(()=>[])` —, así que `[]` por error era indistinguible de `[]` porque
|
||||
el output sigue sin gastar. En un informe pericial eso convertía una consulta
|
||||
fallida en la conclusión más accionable del documento: *fondos localizados
|
||||
sin gastar*.
|
||||
- Nuevo `getStrict` en `useApi`: propaga el fallo en vez de disfrazarlo.
|
||||
`get` no cambia, así que el resto del dashboard mantiene su comportamiento.
|
||||
- El motor (`findSpendingTx`, `advanceForensicHop`, `initForensicTrace`)
|
||||
distingue "no se pudo consultar" de "no hay gasto" y registra los fallos en
|
||||
`trace.queryErrors`.
|
||||
- El informe excluye las ramas con consulta fallida de "fondos localizados
|
||||
sin gastar", las lista en su propia sección y abre las conclusiones
|
||||
avisando de que el rastreo está incompleto. Incluido en los export JSON y MD.
|
||||
- La UI muestra contador de consultas fallidas y sustituye el falso
|
||||
"rastro completo" por un aviso con el detalle de qué falló.
|
||||
- Peritaje: el error al obtener la transacción de origen ya no culpa siempre al
|
||||
txid — distingue un txid inexistente de un fallo del nodo.
|
||||
- Peritaje: el botón "Generar informe" explica por qué está deshabilitado en
|
||||
vez de quedarse inerte sin dar motivo.
|
||||
|
||||
### Cambiado
|
||||
- El aviso de consulta fallida ya no dice que el fallo "suele ser transitorio":
|
||||
cuando se repite sobre la misma dirección no lo es, y ahora lo explica —
|
||||
historial demasiado pesado para servirlo dentro del tiempo de espera, con la
|
||||
indicación de comprobarla a mano antes de dar el rastro por cerrado.
|
||||
|
||||
### Documentación
|
||||
- README: documentadas dos limitaciones reales del peritaje — las ramas que no
|
||||
se pueden comprobar (y por qué cortar es deliberado, no un defecto), y que el
|
||||
tope de volumen mide número de transacciones y no peso, así que una dirección
|
||||
con pocas transacciones muy grandes puede agotar el tiempo de espera igual.
|
||||
|
||||
---
|
||||
## [1.10.0] — 2026-07-16
|
||||
### Añadido
|
||||
|
||||
@@ -22,62 +22,26 @@ Tan importante como saber qué hace es saber qué no hace:
|
||||
|
||||
- **No envía datos a ningún servidor externo** — ni analíticas, ni telemetría, ni consultas a APIs de terceros
|
||||
- **No toca claves privadas** — el watch-only usa solo tu clave pública (xpub); no puede gastar fondos ni conoce tu semilla
|
||||
- **No llega nunca a una identidad** — se detiene en "hot wallet de [servicio]" o "custodio no identificado", y lo dice explícitamente. Ver la sección siguiente
|
||||
- **No identifica a otras personas** — es una herramienta de auto-auditoría, no de vigilancia
|
||||
- **No da consejos de inversión** — analiza privacidad on-chain, nada más
|
||||
- **No afirma más de lo que sabe** — distingue siempre entre CERTEZA, PROBABLE e POSIBLE
|
||||
- **No tiene número de score exacto** — muestra bandas (ALTA/MEDIA/BAJA); la precisión numérica sería falsa
|
||||
- **No muestra el contenido de OP_RETURN** — solo informa de su presencia y tamaño
|
||||
|
||||
---
|
||||
|
||||
## Sí, esto es chain analysis
|
||||
|
||||
Conviene decirlo claro, porque el código está a la vista y cualquiera puede
|
||||
comprobarlo: el peritaje forense aplica **las mismas técnicas que las empresas
|
||||
de vigilancia de cadena**. Rastreo hacia adelante salto a salto, clustering por
|
||||
CIOH, perfilado de direcciones, huella de software, un índice de direcciones de
|
||||
exchanges. Es el arsenal de Chainalysis, técnica por técnica.
|
||||
|
||||
Fingir lo contrario sería insultar tu inteligencia. Lo que cambia no es el
|
||||
método, es todo lo demás:
|
||||
|
||||
- **Quién lo ejecuta.** Corre en tu nodo, con tus datos, bajo tu control. No
|
||||
hay un tercero que acumule los resultados ni los venda a quien pague.
|
||||
- **Sobre quién.** Tu propia actividad, o el rastro de unas monedas que te
|
||||
quitaron a ti. No es un servicio que perfile a desconocidos por encargo.
|
||||
- **Dónde se detiene.** En un servicio, nunca en una persona. Y cuando una
|
||||
heurística no llega, el informe dice que no llega en vez de rellenar el hueco.
|
||||
- **Quién se queda el informe.** Tú. No sale de tu red y decides qué hacer con
|
||||
él — incluido no hacer nada.
|
||||
|
||||
Y hay una razón más, la que de verdad justifica que esto exista:
|
||||
|
||||
> **La misma herramienta que sigue el rastro de un ladrón demuestra lo fácil
|
||||
> que es seguir el tuyo.**
|
||||
|
||||
No hay forma más honesta de entender qué puede deducir un observador de tus
|
||||
monedas que ejecutar sus heurísticas sobre ellas y leer el resultado. Un
|
||||
adversario con estas capacidades ya existe, tenga o no tú una herramienta para
|
||||
verlo. Conocer el arma no es adoptarla: es dejar de estar ciego ante ella.
|
||||
|
||||
Si esto te parece una línea demasiado fina, es una objeción razonable. El
|
||||
código está entero en un archivo para que puedas juzgarlo tú.
|
||||
|
||||
---
|
||||
|
||||
## Cómo verificar que nada sale de tu red
|
||||
|
||||
Todo el código de análisis vive en un único archivo HTML, sin minificar y sin proceso de compilación. Puedes auditarlo tú mismo:
|
||||
El código es un único archivo HTML autocontenido. Puedes auditarlo tú mismo:
|
||||
|
||||
```bash
|
||||
# Buscar cualquier llamada a dominios externos
|
||||
grep -E "fetch\(|XMLHttpRequest|src=\"http|href=\"http" dashboard.html
|
||||
```
|
||||
|
||||
El comando anterior no debe devolver **ninguna** URL externa. Todas las llamadas van a rutas relativas (`/api/`, `/system/`, `/vendor/`) que apuntan a tu propio nodo vía nginx.
|
||||
Verás que todas las llamadas van a rutas relativas (`/api/`, `/system/`) que apuntan a tu propio nodo vía nginx. No hay ninguna llamada a dominios externos en el código de análisis.
|
||||
|
||||
Esto incluye las librerías del frontend (React y Babel): también se sirven desde tu nodo, no desde un CDN. Hasta la versión 1.10.1 se cargaban desde unpkg.com, y aunque un CDN nunca vio qué transacciones analizabas, sí recibía tu IP pública cada vez que abrías el dashboard — es decir, sabía que usabas Txoko, cuándo y desde dónde. Ese metadato es justo lo que esta herramienta enseña a proteger, así que se eliminó.
|
||||
|
||||
Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo necesita tu nodo. Las librerías se descargan una vez durante la instalación y se verifican por hash — ver [SETUP.md](SETUP.md).
|
||||
Las únicas dependencias externas son las librerías del frontend (React, Babel) que se cargan desde CDN al abrir la página. Estas no reciben ningún dato de tus transacciones — solo sirven el código de la interfaz.
|
||||
|
||||
---
|
||||
|
||||
@@ -96,17 +60,6 @@ Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo
|
||||
- Bandas de privacidad con distinción explícita CERTEZA / PROBABLE / POSIBLE
|
||||
- Exportación del informe en JSON y Markdown
|
||||
|
||||
**Auditoría de PSBT — antes de firmar**
|
||||
|
||||
La única función de la app que llega a tiempo: el resto te cuenta lo que ya
|
||||
pasó, esta te avisa cuando todavía puedes cambiar la transacción.
|
||||
|
||||
- Parser completo de BIP174 escrito desde cero, validado contra los cinco vectores inválidos del propio estándar
|
||||
- **Todo offline**: una PSBT ya lleva dentro los importes y scripts de sus entradas, así que no se consulta el nodo. Funciona con el nodo sincronizando
|
||||
- Avisa de las **claves públicas maestras incrustadas**: las PSBT suelen llevarlas dentro y están hechas para compartirse — quien reciba el archivo puede ver todas tus direcciones, tu saldo y tu historial. No puede gastar, pero lo ve todo
|
||||
- Detecta cambio identificable por tipo de dirección, importes redondos que delatan cuál es el pago, envíos a tu propia cartera, carteras multifirma y PSBTs incompletas
|
||||
- Acepta base64, hexadecimal o el archivo `.psbt` que exporta Sparrow
|
||||
|
||||
**Peritaje forense**
|
||||
- Rastreo de fondos robados o perdidos hacia adelante, salto a salto, desde una transacción de origen hasta un punto de parada natural (custodio identificado, dilución, CoinJoin, o fondos aún sin gastar)
|
||||
- A demanda: tú decides cuándo se explora cada salto con el botón "Seguir el rastro" — nada corre sin que lo pidas, igual que el rastro de procedencia hacia atrás. Puedes generar el informe con lo explorado hasta ese momento, sin terminar el rastreo entero
|
||||
@@ -134,7 +87,7 @@ pasó, esta te avisa cuando todavía puedes cambiar la transacción.
|
||||
- Conversor sat/BTC/fiat
|
||||
- Validador de dirección
|
||||
- Detector OP_RETURN
|
||||
- Decodificador de transacción raw
|
||||
- Decodificador PSBT y transacción raw
|
||||
|
||||
---
|
||||
|
||||
@@ -162,29 +115,66 @@ Probado sobre Ubuntu Server 24.04 con HP EliteDesk (i5, 32GB RAM, 2TB NVMe).
|
||||
|
||||
## Instalación
|
||||
|
||||
**La guía completa está en [SETUP.md](SETUP.md)** — paso a paso, con una
|
||||
comprobación después de cada uno para que sepas si vas bien sin tener que
|
||||
descubrirlo al final.
|
||||
### 1. Clonar el repositorio
|
||||
|
||||
Resumen de lo que implica:
|
||||
```bash
|
||||
git clone https://git.bitcointxoko.org/pikaro/txoko-dashboard.git
|
||||
cd txoko-dashboard
|
||||
```
|
||||
|
||||
1. Clonar el repositorio.
|
||||
2. Copiar `dashboard.html` a un **directorio** propio servido por nginx.
|
||||
3. Descargar las librerías (React, Babel y las fuentes) a `vendor/`, junto al
|
||||
dashboard, y verificarlas por hash. No están en el repo: son de terceros y
|
||||
ocupan ~3 MB.
|
||||
4. Opcionalmente, instalar el monitor del sistema como servicio systemd.
|
||||
5. Configurar nginx: el dashboard, un proxy a la API de Mempool y otro al
|
||||
monitor.
|
||||
### 2. Copiar el dashboard
|
||||
|
||||
Un aviso que ahorra disgustos: en nginx, el `alias` del dashboard debe apuntar
|
||||
al **directorio** (con barra final), no al archivo `dashboard.html`. Si apunta
|
||||
al archivo, el navegador no encuentra `vendor/` y verás una página en blanco
|
||||
sin ningún error visible. Es el fallo más común, y en SETUP.md hay una
|
||||
comprobación concreta para descartarlo.
|
||||
```bash
|
||||
cp dashboard.html /var/www/txoko/dashboard.html
|
||||
# o donde lo sirvas con nginx
|
||||
```
|
||||
|
||||
Cuando termines, abre `http://TU-IP:4080/dashboard/` — **con la barra final** —,
|
||||
pulsa CONFIG e introduce la URL de tu Mempool.
|
||||
### 3. Copiar el backend de métricas
|
||||
|
||||
```bash
|
||||
cp system-metrics.js /home/armg/txoko/system-metrics.js
|
||||
```
|
||||
|
||||
Editar `system-metrics.js` y añadir tus credenciales RPC de Bitcoin Core
|
||||
(el archivo del repo usa placeholders).
|
||||
|
||||
### 4. Configurar el servicio systemd
|
||||
|
||||
```bash
|
||||
sudo cp txoko-metrics.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable txoko-metrics
|
||||
sudo systemctl start txoko-metrics
|
||||
```
|
||||
|
||||
### 5. Configurar nginx
|
||||
|
||||
Añadir a tu configuración de nginx:
|
||||
|
||||
```nginx
|
||||
# Dashboard
|
||||
location /dashboard {
|
||||
alias /var/www/txoko/dashboard.html;
|
||||
}
|
||||
|
||||
# API Mempool (ajusta el puerto según tu instalación)
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:8999;
|
||||
}
|
||||
|
||||
# Métricas del sistema
|
||||
location /system/ {
|
||||
proxy_pass http://127.0.0.1:4082;
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Abrir en el navegador
|
||||
|
||||
```
|
||||
http://TU-IP-TAILSCALE:4080/dashboard
|
||||
```
|
||||
|
||||
Introduce la URL de tu Mempool en el modal de configuración y empieza a analizar.
|
||||
|
||||
---
|
||||
|
||||
@@ -192,25 +182,16 @@ pulsa CONFIG e introduce la URL de tu Mempool.
|
||||
|
||||
```
|
||||
txoko-dashboard/
|
||||
├── dashboard.html # La aplicación entera (HTML + CSS + JS en un archivo)
|
||||
├── system-metrics.js # Monitor del nodo (Node.js) — opcional
|
||||
├── txoko-metrics.service # Servicio systemd para el monitor
|
||||
├── instalar-fuentes.sh # Descarga IBM Plex al nodo y genera su CSS
|
||||
├── tests/ # Pruebas de la criptografía contra vectores oficiales
|
||||
├── SETUP.md # Guía de instalación
|
||||
├── FIXTURES.md # Transacciones reales para probar las heurísticas
|
||||
├── CHANGELOG.md
|
||||
├── dashboard.html # Frontend completo (HTML + CSS + JS en un solo archivo)
|
||||
├── system-metrics.js # Backend de métricas del nodo (Node.js)
|
||||
├── txoko-metrics.service # Servicio systemd
|
||||
├── README.md
|
||||
├── SETUP.md
|
||||
├── CHANGELOG.md
|
||||
└── LICENSE
|
||||
```
|
||||
|
||||
Tras la instalación, junto al `dashboard.html` queda además un directorio
|
||||
`vendor/` con las librerías y las fuentes. No está en el repositorio: se
|
||||
descarga y se verifica durante la instalación (ver [SETUP.md](SETUP.md)).
|
||||
|
||||
El frontend es un único archivo HTML. Sin bundler, sin npm, sin proceso de build: Babel transpila el JSX en el navegador, así que lo que lees en el archivo es exactamente lo que se ejecuta — no hay una versión compilada que auditar por separado.
|
||||
|
||||
Las librerías (React, Babel, las fuentes) viven aparte, en `vendor/`, servidas desde tu propio nodo y verificadas por hash durante la instalación. Se dejan fuera del repo a propósito: son código de terceros, y mezclarlas con el tuyo haría más difícil auditar lo que de verdad importa.
|
||||
El frontend es un único archivo HTML autocontenido. Sin bundler, sin npm, sin proceso de build. Babel transpila el JSX en el navegador. Puedes auditarlo todo abriendo el archivo.
|
||||
|
||||
---
|
||||
|
||||
@@ -222,9 +203,7 @@ Las librerías (React, Babel, las fuentes) viven aparte, en `vendor/`, servidas
|
||||
- **Base de datos de entidades parcial** — ~745 direcciones de exchanges, OFAC y minería. Cubre los casos más comunes; no es completa
|
||||
- **Fingerprinting conservador** — requiere varias señales coincidentes; prefiere no detectar antes que detectar mal
|
||||
- **Sin análisis de red** — no cruza datos con otros nodos ni mempool distribuida
|
||||
- **Peritaje forense sin `/outspend`** — el backend Mempool self-hosted no expone ese endpoint (ni `/utxo`), así que cada salto se resuelve recorriendo el historial de la dirección receptora. Más peticiones que un `/outspend` directo, con throttling por lotes para no saturar el nodo
|
||||
- **Ramas que no se pueden comprobar** — algunas direcciones tienen un historial tan pesado que el backend no lo sirve dentro del tiempo de espera (8 s). Cuando pasa, esa rama se marca como **no comprobada** y queda fuera de "fondos localizados sin gastar": el informe dice que no se sabe, en vez de afirmar que los fondos siguen ahí. Es deliberado — cortar protege el nodo, que es la premisa de la herramienta, y una consulta fallida nunca debe leerse como una conclusión. Esas ramas hay que comprobarlas a mano
|
||||
- **El tope de volumen mide número de transacciones, no peso** — el cinturón de seguridad (`LARGE_ADDR_TX_COUNT`, 5000 tx) frena las direcciones con historiales enormes, pero una dirección con pocas transacciones muy grandes puede agotar el tiempo de espera igualmente (visto con una de 59 transacciones de ~15 KB cada una). No hay forma barata de conocer el peso de la respuesta por adelantado, así que el tope no cubre ese caso; lo cubre el manejo de errores descrito arriba
|
||||
- **Peritaje forense sin `/outspend`** — el backend Mempool self-hosted no expone ese endpoint, así que cada salto se resuelve recorriendo el historial de la dirección receptora. Más peticiones que un `/outspend` directo, con throttling por lotes para no saturar el nodo
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,318 +1,233 @@
|
||||
# Instalación de Txoko Node Dashboard
|
||||
# Cómo subir Txoko a Gitea — paso a paso
|
||||
|
||||
Guía completa, de principio a fin. Sigue los pasos en orden y comprueba cada
|
||||
uno antes de pasar al siguiente: cada comprobación te dice si vas bien, en vez
|
||||
de dejarte descubrirlo al final con una pantalla en blanco.
|
||||
|
||||
Todo se hace **en el nodo**, salvo donde se indique lo contrario.
|
||||
Instrucciones exactas. Copiar y pegar en la terminal del Mac.
|
||||
No hace falta entender git para seguir esto.
|
||||
|
||||
---
|
||||
|
||||
## Antes de empezar
|
||||
## PASO 1 — Crear el repo en Gitea (una sola vez)
|
||||
|
||||
Txoko no habla con la red Bitcoin directamente: se apoya en cosas que ya
|
||||
tienes montadas. Necesitas:
|
||||
1. Abre tu instancia de Gitea en el navegador
|
||||
2. Clic en el **+** (arriba a la derecha) → "New Repository"
|
||||
3. Rellena:
|
||||
- **Repository Name:** `txoko-dashboard`
|
||||
- **Description:** `Suite de auditoría de privacidad Bitcoin para nodos propios`
|
||||
- **Visibility:** Private (o Public si quieres compartirlo con la comunidad)
|
||||
- **Initialize repository:** NO marcar (ya traemos nuestros archivos)
|
||||
4. Clic en "Create Repository"
|
||||
5. Gitea te muestra una página con instrucciones — copia la URL del repo,
|
||||
será algo como: `https://gitea.tu-comunidad/tu-usuario/txoko-dashboard.git`
|
||||
|
||||
| Requisito | Para qué |
|
||||
|---|---|
|
||||
| **Bitcoin Core** con `txindex=1` | consultar cualquier transacción, no solo las tuyas |
|
||||
| **Mempool self-hosted** ([mempool/mempool](https://github.com/mempool/mempool)) | la API que Txoko consulta |
|
||||
| **Fulcrum** ([cculianu/Fulcrum](https://github.com/cculianu/Fulcrum)) | índice de direcciones; Mempool lo usa por debajo |
|
||||
| **nginx** | sirve el dashboard y hace de proxy hacia lo anterior |
|
||||
| **Node.js ≥ 18** | solo para el monitor del sistema (paso 4) |
|
||||
---
|
||||
|
||||
Si Mempool self-hosted ya te funciona en el navegador, tienes todo lo demás.
|
||||
|
||||
Comprueba que la API responde antes de seguir. Ajusta el puerto al tuyo:
|
||||
## PASO 2 — Configurar git en el Mac (una sola vez, si no lo tienes)
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8999/api/v1/fees/recommended
|
||||
git config --global user.name "tu nombre"
|
||||
git config --global user.email "tu@email.com"
|
||||
```
|
||||
|
||||
Debe devolver un JSON con comisiones. Si no responde, arregla eso primero:
|
||||
Txoko no puede funcionar sin ello.
|
||||
|
||||
> **Nunca expongas estos puertos a internet.** Txoko está pensado para
|
||||
> accederse por Tailscale, VPN o red local.
|
||||
Comprueba que git está instalado:
|
||||
```bash
|
||||
git --version
|
||||
```
|
||||
Si no está: `brew install git`
|
||||
|
||||
---
|
||||
|
||||
## Paso 1 — Descargar los archivos
|
||||
## PASO 3 — Crear el repo local y primer commit (una sola vez)
|
||||
|
||||
```bash
|
||||
git clone https://git.bitcointxoko.org/pikaro/txoko-dashboard.git
|
||||
cd txoko-dashboard
|
||||
# Crear carpeta del proyecto en el Mac
|
||||
mkdir ~/txoko-dashboard
|
||||
cd ~/txoko-dashboard
|
||||
|
||||
# Copiar los archivos del repo que te he preparado
|
||||
# (descarga los archivos de esta conversación y cópialos aquí)
|
||||
|
||||
# Inicializar git
|
||||
git init
|
||||
git branch -M main
|
||||
|
||||
# Añadir todos los archivos
|
||||
git add .
|
||||
|
||||
# Primer commit — el historial empieza aquí
|
||||
git commit -m "inicio: dashboard de privacidad Bitcoin con análisis on-chain"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Paso 2 — Elegir dónde vivirá el dashboard
|
||||
|
||||
Esta decisión condiciona el resto, así que conviene hacerla a conciencia.
|
||||
|
||||
Necesitas un **directorio** propio para Txoko. No vale colocar el
|
||||
`dashboard.html` suelto en cualquier sitio: la aplicación carga sus librerías
|
||||
desde un subdirectorio `vendor/` **junto al propio archivo**, así que ambos
|
||||
tienen que convivir.
|
||||
## PASO 4 — Conectar con Gitea y subir (una sola vez)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/txoko
|
||||
sudo cp dashboard.html /var/www/txoko/
|
||||
# Sustituye la URL por la de tu repo de Gitea
|
||||
git remote add origin https://gitea.tu-comunidad/tu-usuario/txoko-dashboard.git
|
||||
|
||||
# Subir
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
Puedes usar otra ruta; solo recuerda cuál es, porque aparece en los pasos 3 y 5.
|
||||
Gitea te pedirá usuario y contraseña la primera vez.
|
||||
Si quieres evitar introducirlos cada vez, crea un token en
|
||||
Gitea → Settings → Applications → "Generate Token" y úsalo como contraseña.
|
||||
|
||||
---
|
||||
|
||||
## Paso 3 — Instalar las librerías del frontend
|
||||
|
||||
Txoko usa React y Babel, y la tipografía IBM Plex. **Se sirven desde tu propio
|
||||
nodo, nunca desde un CDN externo.**
|
||||
|
||||
El motivo es de privacidad, no de comodidad: un CDN no vería qué transacciones
|
||||
analizas, pero sí recibiría tu IP y la hora cada vez que abres el dashboard —
|
||||
sabría que usas Txoko, cuándo y desde dónde. Justo el metadato que esta
|
||||
herramienta enseña a proteger. Sirviéndolas en local, *"ninguna consulta sale
|
||||
de tu red"* es literal, y el dashboard funciona sin conexión a internet.
|
||||
|
||||
No están en el repositorio porque son código de terceros y ocupan unos 3 MB.
|
||||
|
||||
### React y Babel
|
||||
## PASO 5 — Flujo de trabajo normal (cada vez que yo te dé un archivo nuevo)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/txoko/vendor
|
||||
cd /var/www/txoko/vendor
|
||||
sudo curl -sSLO https://unpkg.com/react@18.3.1/umd/react.production.min.js
|
||||
sudo curl -sSLO https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js
|
||||
sudo curl -sSL -o babel.min.js https://unpkg.com/@babel/standalone@7.23.10/babel.min.js
|
||||
```
|
||||
cd ~/txoko-dashboard
|
||||
|
||||
**Comprueba que has recibido lo que esperabas.** No te fíes: verifícalo.
|
||||
# Copiar el archivo actualizado descargado de esta conversación
|
||||
cp ~/Downloads/txoko-dashboard.html ./dashboard.html
|
||||
|
||||
```bash
|
||||
sha256sum *.js
|
||||
```
|
||||
# Ver qué cambió (opcional pero útil)
|
||||
git diff dashboard.html
|
||||
|
||||
Debe dar exactamente esto:
|
||||
# Registrar el cambio con un mensaje descriptivo
|
||||
git add dashboard.html
|
||||
git commit -m "fix: descripción breve de lo que se arregló"
|
||||
|
||||
```
|
||||
85ba0c7207cf1b1850e40372f26a7e69a481457a29649b7ca19bbfce8604f30c babel.min.js
|
||||
35f4f974f4b2bcd44da73963347f8952e341f83909e4498227d4e26b98f66f0d react-dom.production.min.js
|
||||
d949f1c3687aedadcedac85261865f29b17cd273997e7f6b2bfc53b2f9d4c4dd react.production.min.js
|
||||
```
|
||||
# Subir a Gitea
|
||||
git push
|
||||
|
||||
Si alguno no coincide, **para aquí**: has recibido algo distinto de lo esperado.
|
||||
|
||||
Las versiones están fijadas a propósito. Usar un rango como `react@18` dejaría
|
||||
que el servidor decidiera qué versión te entrega, y cambiaría con el tiempo sin
|
||||
que te enteres.
|
||||
|
||||
### Las fuentes
|
||||
|
||||
```bash
|
||||
sudo /ruta/al/repo/instalar-fuentes.sh /var/www/txoko/vendor/fonts
|
||||
```
|
||||
|
||||
El script descarga IBM Plex (licencia libre OFL), comprueba cada archivo y
|
||||
aborta si algo falla. Son unos 350 KB.
|
||||
|
||||
---
|
||||
|
||||
## Paso 4 — El monitor del sistema (opcional)
|
||||
|
||||
Alimenta la pestaña NODO: CPU, RAM, disco, estado de Bitcoin Core y logs. Sin
|
||||
esto el resto de la aplicación funciona igual, solo que esa pestaña queda vacía.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/txoko
|
||||
cp system-metrics.js ~/txoko/
|
||||
```
|
||||
|
||||
Edita `~/txoko/system-metrics.js` y sustituye los dos marcadores por tus
|
||||
credenciales RPC de Bitcoin Core (las de tu `bitcoin.conf`):
|
||||
|
||||
```js
|
||||
const RPC_USER = "TU_RPC_USER";
|
||||
const RPC_PASS = "TU_RPC_PASSWORD";
|
||||
```
|
||||
|
||||
Instala el servicio. Antes revisa el `.service`, porque trae rutas y usuario
|
||||
que probablemente tengas que ajustar a los tuyos:
|
||||
|
||||
```bash
|
||||
nano txoko-metrics.service
|
||||
sudo cp txoko-metrics.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now txoko-metrics
|
||||
```
|
||||
|
||||
**Comprueba que arrancó:**
|
||||
|
||||
```bash
|
||||
systemctl is-active txoko-metrics # debe decir: active
|
||||
curl -s http://127.0.0.1:4082/system/info | head -c 200
|
||||
```
|
||||
|
||||
> Si `systemctl status` muestra un punto que no es verde pero pone
|
||||
> `active (running)`, está bien. Lo que importa es el texto.
|
||||
|
||||
---
|
||||
|
||||
## Paso 5 — Configurar nginx
|
||||
|
||||
Aquí es donde más gente se atasca, así que léelo con calma.
|
||||
|
||||
Añade esto a tu configuración de nginx (si usas Mempool self-hosted, dentro del
|
||||
mismo bloque `server`):
|
||||
|
||||
```nginx
|
||||
# Dashboard — OJO: alias a un DIRECTORIO, con barra final
|
||||
location /dashboard/ {
|
||||
alias /var/www/txoko/;
|
||||
index dashboard.html;
|
||||
try_files $uri $uri/ /dashboard/dashboard.html;
|
||||
}
|
||||
|
||||
# API de Mempool — ajusta el puerto al de tu instalación
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8999;
|
||||
}
|
||||
|
||||
# Monitor del sistema (solo si hiciste el paso 4)
|
||||
location /system/ {
|
||||
proxy_pass http://127.0.0.1:4082;
|
||||
}
|
||||
```
|
||||
|
||||
**El detalle que rompe la instalación:** el `alias` tiene que apuntar al
|
||||
**directorio**, con barra final, no al archivo `dashboard.html`. Si apunta al
|
||||
archivo, el navegador no encontrará `vendor/` y verás una **página en blanco**
|
||||
sin ningún mensaje de error. Es el fallo más común de esta instalación.
|
||||
|
||||
Recarga nginx:
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
# Copiar al nodo (igual que antes)
|
||||
scp dashboard.html armg@100.116.19.86:/home/armg/txoko/dashboard.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Paso 6 — Comprobar que funciona
|
||||
## Mensajes de commit — ejemplos
|
||||
|
||||
Antes de abrir el navegador, verifica desde el propio nodo. Ajusta el puerto:
|
||||
|
||||
```bash
|
||||
# El dashboard llega
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4080/dashboard/
|
||||
|
||||
# Y las librerías TAMBIÉN — esto es lo que suele fallar
|
||||
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
|
||||
http://127.0.0.1:4080/dashboard/vendor/react.production.min.js
|
||||
```
|
||||
|
||||
La segunda debe responder **`200 application/javascript`**.
|
||||
|
||||
Si devuelve `200 text/html` la instalación **no** está bien: nginx te está
|
||||
sirviendo otra cosa (normalmente el index de Mempool) en lugar del archivo.
|
||||
Revisa el `alias` del paso 5. Fíjate en que aquí no basta con mirar el código
|
||||
200 — hay que mirar el tipo de contenido.
|
||||
|
||||
Ahora sí, abre en el navegador:
|
||||
El mensaje va después de `-m` y describe QUÉ cambiaste.
|
||||
No tiene que ser perfecto, solo útil para ti en el futuro.
|
||||
|
||||
```
|
||||
http://TU-IP:4080/dashboard/
|
||||
"fix: timeout en consultas al nodo"
|
||||
"feat: score por bandas ALTA/MEDIA/BAJA"
|
||||
"fix: responsive pestaña Mempool en móvil"
|
||||
"fix: detección Whirlpool con tolerancia 2%"
|
||||
"feat: fingerprinting wallet completo"
|
||||
"fix: umbral dust por tipo de salida"
|
||||
"docs: actualizar README con nuevas funcionalidades"
|
||||
```
|
||||
|
||||
**Con la barra final.** Sin ella, el navegador busca las librerías un nivel por
|
||||
encima y no las encuentra.
|
||||
|
||||
Pulsa **CONFIG** e introduce la URL de tu Mempool (por ejemplo
|
||||
`http://TU-IP:4080`). Dale a PROBAR: si dice "Conexión exitosa", ya está.
|
||||
|
||||
### Si ves una página en blanco
|
||||
|
||||
Abre la consola del navegador (F12). Si aparece `SyntaxError: Unexpected
|
||||
token '<'`, es exactamente el problema del `alias` del paso 5: nginx está
|
||||
devolviendo HTML donde debería devolver JavaScript.
|
||||
Convención (opcional pero ordenada):
|
||||
- `fix:` — corrige algo que no funcionaba bien
|
||||
- `feat:` — añade algo nuevo
|
||||
- `docs:` — solo documentación
|
||||
|
||||
---
|
||||
|
||||
## HTTPS — necesario para el watch-only (opcional)
|
||||
## Si algo sale mal
|
||||
|
||||
**Subiste algo que no querías:**
|
||||
```bash
|
||||
git revert HEAD
|
||||
git push
|
||||
```
|
||||
|
||||
**Quieres volver a una versión anterior:**
|
||||
```bash
|
||||
git log --oneline # ver el historial
|
||||
git checkout HASH_DEL_COMMIT -- dashboard.html # recuperar ese archivo
|
||||
```
|
||||
|
||||
**Ver el historial:**
|
||||
```bash
|
||||
git log --oneline
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resumen del flujo
|
||||
|
||||
```
|
||||
Claude te da dashboard.html
|
||||
↓
|
||||
cp ~/Downloads/dashboard.html ./dashboard.html
|
||||
↓
|
||||
git add . && git commit -m "descripción"
|
||||
↓
|
||||
git push
|
||||
↓
|
||||
scp dashboard.html armg@100.116.19.86:/home/armg/txoko/
|
||||
```
|
||||
|
||||
Cuatro comandos después de la descarga. Siempre los mismos.
|
||||
|
||||
---
|
||||
|
||||
## HTTPS para watch-only (opcional)
|
||||
|
||||
La función watch-only (derivar tus direcciones desde el xpub) usa la Web Crypto
|
||||
API del navegador, que **solo funciona sobre HTTPS**. Por HTTP el resto de la
|
||||
aplicación funciona igual; solo esa función queda deshabilitada. Las etiquetas
|
||||
BIP-329 no necesitan HTTPS.
|
||||
API del navegador, que **solo funciona sobre HTTPS**. Si sirves el dashboard por
|
||||
HTTP, watch-only no estará disponible — el resto de la app funciona igual. Las
|
||||
etiquetas BIP-329 no necesitan HTTPS.
|
||||
|
||||
Si accedes por Tailscale o red local no tendrás un certificado válido, así que
|
||||
toca generar uno autofirmado.
|
||||
Si quieres usar watch-only y tu nodo va por HTTP (por ejemplo, acceso por
|
||||
Tailscale sin certificado), puedes generar un certificado autofirmado. Es lo que
|
||||
sigue. Todo se hace en el nodo.
|
||||
|
||||
### 1. Generar el certificado
|
||||
### 1. Generar el certificado autofirmado
|
||||
|
||||
Sustituye la IP por la de tu nodo:
|
||||
Sustituye la IP por la de tu nodo (Tailscale, local, etc.):
|
||||
|
||||
```bash
|
||||
sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||||
-keyout /etc/ssl/private/txoko.key \
|
||||
-out /etc/ssl/certs/txoko.crt \
|
||||
-subj "/CN=100.64.0.5" \
|
||||
-addext "subjectAltName=IP:100.64.0.5"
|
||||
-subj "/CN=100.116.19.86" \
|
||||
-addext "subjectAltName=IP:100.116.19.86"
|
||||
```
|
||||
|
||||
El `subjectAltName` no es opcional: sin él los navegadores modernos rechazan el
|
||||
certificado aunque el `CN` sea correcto.
|
||||
### 2. Configurar nginx para servir HTTPS
|
||||
|
||||
### 2. Servirlo en nginx
|
||||
|
||||
Duplica tu bloque `server` en otro puerto (4081 en este ejemplo) añadiendo:
|
||||
Añade un bloque `server` que escuche en un puerto con SSL (por ejemplo 4081),
|
||||
apuntando al certificado recién creado e incluyendo la misma configuración que
|
||||
tu servidor HTTP:
|
||||
|
||||
```nginx
|
||||
listen 4081 ssl;
|
||||
ssl_certificate /etc/ssl/certs/txoko.crt;
|
||||
ssl_certificate_key /etc/ssl/private/txoko.key;
|
||||
server {
|
||||
listen 4081 ssl;
|
||||
listen [::]:4081 ssl;
|
||||
server_name _;
|
||||
ssl_certificate /etc/ssl/certs/txoko.crt;
|
||||
ssl_certificate_key /etc/ssl/private/txoko.key;
|
||||
ssl_session_timeout 4h;
|
||||
ssl_protocols TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
# Incluye aquí tu misma config (location /dashboard, /api/, etc.)
|
||||
# Si ya tienes esos location en un snippet, basta con incluirlo:
|
||||
# include /etc/nginx/snippets/tu-config.conf;
|
||||
}
|
||||
```
|
||||
|
||||
Los `location` son los mismos del paso 5. Recarga con `sudo nginx -t &&
|
||||
sudo systemctl reload nginx`.
|
||||
Si el bloque `location /dashboard` ya viene de un snippet que incluyes, **no lo
|
||||
dupliques** dentro del server SSL: nginx dará error `duplicate location`.
|
||||
|
||||
### 3. Aceptar el certificado la primera vez
|
||||
|
||||
Al entrar en `https://TU-IP:4081/dashboard/` el navegador avisará de que el
|
||||
certificado no es de confianza. Es lo esperado: lo has firmado tú. Acepta la
|
||||
excepción una vez.
|
||||
|
||||
Que sea autofirmado no lo hace menos seguro **para este uso**: cifra igual, y
|
||||
como el certificado lo has generado tú en tu propia máquina, nadie externo
|
||||
puede suplantarlo. Lo que no tienes es el respaldo de una autoridad
|
||||
certificadora, que aquí no aporta nada porque el servidor y el cliente son
|
||||
tuyos.
|
||||
|
||||
---
|
||||
|
||||
## Actualizar a una versión nueva
|
||||
Comprueba y recarga:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
sudo cp dashboard.html /var/www/txoko/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Y recarga el navegador con **Ctrl+Shift+R** (o Cmd+Shift+R en Mac) para saltarte
|
||||
la caché.
|
||||
### 3. Confiar el certificado la primera vez
|
||||
|
||||
Las librerías de `vendor/` no hace falta volver a descargarlas salvo que el
|
||||
CHANGELOG diga lo contrario. Si además cambia `system-metrics.js`, cópialo de
|
||||
nuevo (conservando tus credenciales) y reinicia con
|
||||
`sudo systemctl restart txoko-metrics`.
|
||||
Abre `https://TU-IP:4081/dashboard/` en el navegador. Como el certificado es
|
||||
autofirmado, el navegador avisará de que la conexión no es privada. Es esperado
|
||||
—lo creaste tú— y es seguro en tu propia red:
|
||||
|
||||
---
|
||||
- **Safari:** clic en "visitar este sitio web" (abajo del aviso) y confirma
|
||||
- **Chrome/Brave:** "Configuración avanzada" → "Acceder a TU-IP (no seguro)"
|
||||
|
||||
## Problemas frecuentes
|
||||
A partir de ahí el navegador recuerda la excepción y la Web Crypto API queda
|
||||
disponible, así que watch-only funcionará.
|
||||
|
||||
> Nota: un certificado autofirmado es perfectamente válido para uso personal en
|
||||
> tu propia red. El aviso del navegador existe porque no hay una autoridad
|
||||
> certificadora de por medio, no porque la conexión sea insegura — el tráfico va
|
||||
> cifrado igual.
|
||||
|
||||
| Síntoma | Causa habitual |
|
||||
|---|---|
|
||||
| Página en blanco, consola con `Unexpected token '<'` | El `alias` de nginx apunta al archivo y no al directorio (paso 5) |
|
||||
| Página en blanco al entrar sin barra final | Entra en `/dashboard/`, con barra |
|
||||
| Aparece "DEMO · SIN NODO" | Falta configurar la URL del Mempool en CONFIG |
|
||||
| La pestaña NODO está vacía | El servicio `txoko-metrics` no corre, o falta el `location /system/` |
|
||||
| Watch-only no aparece | Estás entrando por HTTP; requiere HTTPS |
|
||||
| La app dice que el nodo no responde | Comprueba la API con el `curl` de "Antes de empezar" |
|
||||
|
||||
+123
-861
File diff suppressed because it is too large
Load Diff
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Txoko — instalar IBM Plex en local (elimina la dependencia de Google Fonts)
|
||||
#
|
||||
# Descarga las fuentes IBM Plex (licencia OFL, libre) y genera el CSS que las
|
||||
# declara. Tras ejecutarlo, el dashboard deja de contactar con
|
||||
# fonts.googleapis.com y fonts.gstatic.com.
|
||||
#
|
||||
# Por qué importa: Google no ve qué transacciones analizas, pero sí recibe tu
|
||||
# IP, la hora y la página desde la que se pide la fuente, cada vez que abres el
|
||||
# dashboard. Para una herramienta de privacidad, sobra.
|
||||
#
|
||||
# Fuente: paquetes npm oficiales de IBM servidos por unpkg — el mismo origen
|
||||
# del que ya se descargan React y Babel durante la instalación.
|
||||
#
|
||||
# Ejecutar EN EL NODO:
|
||||
# chmod +x instalar-fuentes.sh && sudo ./instalar-fuentes.sh
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -uo pipefail
|
||||
|
||||
DEST="${1:-/usr/share/nginx/html/vendor/fonts}"
|
||||
MONO="https://unpkg.com/@ibm/plex-mono@1.1.0/fonts/complete/woff2"
|
||||
SANS="https://unpkg.com/@ibm/plex-sans@1.1.0/fonts/complete/woff2"
|
||||
|
||||
echo "→ Instalando fuentes en: $DEST"
|
||||
mkdir -p "$DEST"
|
||||
cd "$DEST" || { echo "✗ No se pudo entrar en $DEST"; exit 1; }
|
||||
|
||||
# Pesos que usa el dashboard: Mono 400/600/700, Sans 400/500/600.
|
||||
# Formato woff2 (el más comprimido, soportado por todo navegador actual).
|
||||
URLS="
|
||||
$MONO/IBMPlexMono-Regular.woff2
|
||||
$MONO/IBMPlexMono-SemiBold.woff2
|
||||
$MONO/IBMPlexMono-Bold.woff2
|
||||
$SANS/IBMPlexSans-Regular.woff2
|
||||
$SANS/IBMPlexSans-Medium.woff2
|
||||
$SANS/IBMPlexSans-SemiBold.woff2
|
||||
"
|
||||
|
||||
fallos=0
|
||||
for url in $URLS; do
|
||||
archivo=$(basename "$url")
|
||||
printf " %-32s" "$archivo"
|
||||
# -w escribe el código HTTP para poder diagnosticar si algo va mal
|
||||
codigo=$(curl -sSL -o "$archivo" -w "%{http_code}" "$url" 2>/dev/null)
|
||||
tam=$(stat -c%s "$archivo" 2>/dev/null || echo 0)
|
||||
if [ "$codigo" = "200" ] && [ "$tam" -gt 10000 ]; then
|
||||
echo "ok ($((tam/1024)) KB)"
|
||||
else
|
||||
echo "FALLÓ (HTTP $codigo, $tam bytes)"
|
||||
fallos=$((fallos+1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$fallos" -gt 0 ]; then
|
||||
echo
|
||||
echo "✗ $fallos archivo(s) no se descargaron correctamente."
|
||||
echo " No despliegues el dashboard nuevo todavía — avísame con esta salida."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── CSS que declara las fuentes ──────────────────────────────────────────────
|
||||
# font-display:swap → el texto se ve desde el primer momento con la fuente de
|
||||
# respaldo y se sustituye al cargar la definitiva. Sin página en blanco.
|
||||
cat > ibm-plex.css <<'CSS'
|
||||
/* IBM Plex — servido desde tu propio nodo. Licencia OFL (IBM).
|
||||
Sustituye a fonts.googleapis.com: ninguna petición sale de tu red. */
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url('IBMPlexMono-Regular.woff2') format('woff2')}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;src:url('IBMPlexMono-SemiBold.woff2') format('woff2')}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:700;font-display:swap;src:url('IBMPlexMono-Bold.woff2') format('woff2')}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url('IBMPlexSans-Regular.woff2') format('woff2')}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url('IBMPlexSans-Medium.woff2') format('woff2')}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url('IBMPlexSans-SemiBold.woff2') format('woff2')}
|
||||
CSS
|
||||
|
||||
echo
|
||||
echo "✓ Fuentes instaladas y CSS generado."
|
||||
echo " Total: $(du -sh . | cut -f1)"
|
||||
echo
|
||||
echo " Comprueba que nginx las sirve (debe dar 200):"
|
||||
echo " curl -sk -o /dev/null -w '%{http_code}\\n' https://localhost:4081/vendor/fonts/ibm-plex.css"
|
||||
echo
|
||||
echo " Si da 200, ya puedes desplegar el dashboard.html nuevo."
|
||||
+2
-46
@@ -168,33 +168,10 @@ async function getDisk() {
|
||||
}
|
||||
|
||||
// ── Procesos destacados (nombre limpio del binario) ────────────────────────
|
||||
// Tiempo de CPU consumido por un proceso, en ticks, desde /proc/PID/stat.
|
||||
// Campos 14 (utime) y 15 (stime). El nombre del proceso va entre paréntesis y
|
||||
// puede contener espacios, así que se corta por el ÚLTIMO ')' antes de partir.
|
||||
function readProcCpuTicks(pid) {
|
||||
try {
|
||||
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
const resto = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
||||
// resto[0] es el campo 3 (estado), así que utime=campo14 → resto[11]
|
||||
const utime = parseInt(resto[11], 10);
|
||||
const stime = parseInt(resto[12], 10);
|
||||
if (Number.isNaN(utime) || Number.isNaN(stime)) return null;
|
||||
return utime + stime;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// OJO con la columna %CPU de `ps`: NO es el consumo actual, sino la media del
|
||||
// proceso desde que arrancó (tiempo de CPU / tiempo de vida). Un proceso que
|
||||
// trabajó mucho al principio y ahora está ocioso sigue mostrando un número
|
||||
// alto días después, y se lee como si estuviera saturando la máquina.
|
||||
// Aquí se mide de verdad: dos lecturas de /proc separadas 500 ms, el mismo
|
||||
// método que ya usa getCpuUsage para el total del sistema. Las dos esperas
|
||||
// corren en paralelo (van dentro del mismo Promise.all), así que no cuesta
|
||||
// tiempo extra.
|
||||
async function getProcesses() {
|
||||
const result = await sh("ps aux --no-headers --sort=-%mem | head -8");
|
||||
if (!result) return [];
|
||||
const filas = result.split("\n").map(line => {
|
||||
return result.split("\n").map(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
// Nombre limpio: basename del ejecutable; si es un intérprete (node,
|
||||
// python...), añade el basename del script que ejecuta.
|
||||
@@ -213,33 +190,12 @@ async function getProcesses() {
|
||||
}
|
||||
command = command.slice(0, 24);
|
||||
return {
|
||||
pid: parts[1],
|
||||
user: parts[0],
|
||||
cpuMedia: parseFloat(parts[2]), // media desde el arranque (lo que da ps)
|
||||
cpu: parseFloat(parts[2]),
|
||||
mem: parseFloat(parts[3]),
|
||||
command,
|
||||
};
|
||||
}).filter(p => p.mem > 0.5);
|
||||
|
||||
const ticks1 = filas.map(p => readProcCpuTicks(p.pid));
|
||||
await sleep(500);
|
||||
const ticks2 = filas.map(p => readProcCpuTicks(p.pid));
|
||||
|
||||
const USER_HZ = 100; // estándar en Linux
|
||||
const VENTANA_S = 0.5;
|
||||
const nucleos = os.cpus().length || 1;
|
||||
|
||||
return filas.map((p, i) => {
|
||||
let cpu = null, cpuSistema = null;
|
||||
if (ticks1[i] !== null && ticks2[i] !== null) {
|
||||
const seg = (ticks2[i] - ticks1[i]) / USER_HZ;
|
||||
// % de UN núcleo (criterio de top/ps: puede pasar de 100 si va en varios)
|
||||
cpu = Math.max(0, Math.round((seg / VENTANA_S) * 1000) / 10);
|
||||
// % del total de la máquina, que es lo que suele querer saberse
|
||||
cpuSistema = Math.round((cpu / nucleos) * 10) / 10;
|
||||
}
|
||||
return { user: p.user, command: p.command, mem: p.mem, cpu, cpuSistema, cpuMedia: p.cpuMedia };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Load / Uptime ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Pruebas de la criptografía
|
||||
|
||||
Verifican la derivación watch-only (BIP32, secp256k1, RIPEMD-160, bech32)
|
||||
contra los **vectores oficiales de los estándares**, no contra resultados
|
||||
propios. Si un cambio rompe algo, estas pruebas lo dicen.
|
||||
|
||||
## Cómo ejecutarlas
|
||||
|
||||
Extraer el bloque criptográfico de `dashboard.html` a `crypto.js` (las líneas
|
||||
que van desde `const B32 = {` hasta el final de `deriveAddresses`), añadir al
|
||||
principio `const { webcrypto } = require("crypto"); const crypto = webcrypto;`
|
||||
y al final la exportación:
|
||||
|
||||
module.exports = { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses };
|
||||
|
||||
Después:
|
||||
|
||||
node test1.js # RIPEMD-160 y aritmética de curva
|
||||
node test2.js # BIP32 y bech32 contra vectores oficiales
|
||||
node test3.js # búsqueda de casos borde (diagnóstico)
|
||||
node test4.js # regresión de los fallos ya corregidos
|
||||
|
||||
## Qué cubren
|
||||
|
||||
- **test1** — RIPEMD-160 con los seis vectores del estándar (incluido el de un
|
||||
millón de caracteres), generador de secp256k1, múltiplos conocidos, y que
|
||||
comprimir y descomprimir un punto sea reversible.
|
||||
- **test2** — BIP32: clave pública y chain code de la raíz, y derivación no
|
||||
endurecida `m/0`, contra los vectores 1 y 2 del propio BIP32. bech32: la
|
||||
dirección P2WPKH del generador, en mainnet y testnet (BIP173).
|
||||
- **test3** — sondeo de casos borde. Fue el que encontró los cuatro fallos de
|
||||
validación corregidos el 2026-07-27.
|
||||
- **test4** — comprueba que esos cuatro siguen cerrados: checksum rota, xpub
|
||||
truncado, índice endurecido, índice negativo. Y que un `tpub` genera
|
||||
direcciones de testnet, no de mainnet.
|
||||
|
||||
## Lo que estas pruebas NO cubren
|
||||
|
||||
La matemática es correcta, pero eso no es una auditoría. No cubren análisis
|
||||
formal ni una revisión independiente: quien las escribió conoce la
|
||||
implementación y comparte sus supuestos, que es justo el sesgo que rompe un
|
||||
revisor externo. Siguen haciendo falta ojos de fuera antes de difundir el
|
||||
proyecto ampliamente.
|
||||
|
||||
Tampoco aplican aquí los ataques de canal lateral: todo esto maneja **solo
|
||||
claves públicas**. No hay secreto que filtrar; lo único que importa es que el
|
||||
resultado sea correcto, y eso es lo que se comprueba.
|
||||
@@ -1,44 +0,0 @@
|
||||
// WebCrypto en Node
|
||||
const { webcrypto } = require('crypto');
|
||||
global.crypto = webcrypto;
|
||||
const fs=require("fs");
|
||||
const { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses } = require('/tmp/cripto/crypto.js');
|
||||
|
||||
const hex = a => Array.from(a).map(b=>b.toString(16).padStart(2,'0')).join('');
|
||||
let pass=0, fail=0;
|
||||
const check=(nombre, got, want)=>{
|
||||
const ok = got===want;
|
||||
console.log(` ${ok?'✓':'✗'} ${nombre}`);
|
||||
if(!ok){ console.log(` obtenido: ${got}`); console.log(` esperado: ${want}`); fail++; } else pass++;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
console.log("=== 1. RIPEMD-160 (vectores del estándar) ===");
|
||||
const enc = s => new TextEncoder().encode(s);
|
||||
check('""', hex(ripemd160(enc(""))), "9c1185a5c5e9fc54612808977ee8f548b2258d31");
|
||||
check('"a"', hex(ripemd160(enc("a"))), "0bdc9d2d256b3ee9daae347be6f4dc835a467ffe");
|
||||
check('"abc"', hex(ripemd160(enc("abc"))), "8eb208f7e05d987a9b044a8e98c6b087f15a0bfc");
|
||||
check('"message digest"', hex(ripemd160(enc("message digest"))), "5d0689ef49d2fae572b881b123a85ffa21595f36");
|
||||
check('abcdefghijklmnopqrstuvwxyz', hex(ripemd160(enc("abcdefghijklmnopqrstuvwxyz"))), "f71c27109c692c1b56bbdceb5b9d2865b3708dbc");
|
||||
check('1M x "a"', hex(ripemd160(enc("a".repeat(1000000)))), "52783243c1697bdbe16d37f97f68f08325dc1528");
|
||||
|
||||
console.log("\n=== 2. secp256k1: G y múltiplos conocidos ===");
|
||||
const G = SECP.G;
|
||||
check('G comprimido', hex(SECP.compress(G)), "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798");
|
||||
check('2G', hex(SECP.compress(SECP.mulPoint(2n, G))), "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5");
|
||||
check('3G', hex(SECP.compress(SECP.mulPoint(3n, G))), "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9");
|
||||
// n-1 * G = -G (mismo x, y opuesta)
|
||||
const nm1 = SECP.mulPoint(SECP.N - 1n, G);
|
||||
check('(n-1)G tiene la x de G', nm1[0].toString(16), G[0].toString(16));
|
||||
check('(n-1)G tiene y opuesta', ((nm1[1] + G[1]) % SECP.P).toString(), "0");
|
||||
|
||||
console.log("\n=== 3. compress → decompress (ida y vuelta) ===");
|
||||
for (const k of [1n, 2n, 7n, 12345n, 0xdeadbeefn]) {
|
||||
const pt = SECP.mulPoint(k, G);
|
||||
const c = SECP.compress(pt);
|
||||
const d = SECP.decompress(c);
|
||||
check(`k=${k}`, hex(SECP.compress(d)), hex(c));
|
||||
}
|
||||
|
||||
console.log(`\nRESULTADO: ${pass} correctas, ${fail} incorrectas`);
|
||||
})();
|
||||
@@ -1,40 +0,0 @@
|
||||
const { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses } = require('/tmp/cripto/crypto.js');
|
||||
const hex = a => Array.from(a).map(b=>b.toString(16).padStart(2,'0')).join('');
|
||||
let pass=0, fail=0;
|
||||
const check=(n,g,w)=>{ const ok=g===w; console.log(` ${ok?'✓':'✗'} ${n}`); if(!ok){console.log(` obtenido: ${g}`);console.log(` esperado: ${w}`);fail++;}else pass++; };
|
||||
|
||||
(async () => {
|
||||
// ── BIP32, vectores oficiales del estándar ──
|
||||
// Vector 1: seed 000102...0e0f
|
||||
// m/0/1 derivado SOLO con clave pública (derivación no endurecida)
|
||||
console.log("=== 4. BIP32 — vector 1 oficial, derivación pública ===");
|
||||
// xpub de m (raíz) del vector 1
|
||||
const M = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
|
||||
const raw = B32.decodeBase58(M);
|
||||
const chain = raw.slice(13,45), pub = raw.slice(45,78);
|
||||
check("clave pública de m", hex(pub), "0339a36013301597daef41fbe593a02cc513d0b55527ec2df1050e2e8ff49c85c2");
|
||||
check("chain code de m", hex(chain), "873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508");
|
||||
|
||||
// m/0 → xpub esperado del estándar
|
||||
const m0 = await deriveChildPubkey(pub, chain, 0);
|
||||
// del vector oficial: xpub de m/0'/1 ... usamos m/0 no endurecido del vector 2
|
||||
console.log("\n=== 5. BIP32 — vector 2 oficial (m/0, no endurecida) ===");
|
||||
const M2 = "xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB";
|
||||
const r2 = B32.decodeBase58(M2);
|
||||
const c2 = r2.slice(13,45), p2 = r2.slice(45,78);
|
||||
const d2 = await deriveChildPubkey(p2, c2, 0);
|
||||
check("m/0 clave pública", hex(d2.pub), "02fc9e5af0ac8d9b3cecfe2a888e2117ba3d089d8585886c9c826b6b22a98d12ea");
|
||||
check("m/0 chain code", hex(d2.chain), "f0909affaa7ee7abe5dd4e100598d4dc53cd709d5a5c2cac40e7412f232f7c9c");
|
||||
|
||||
// m/0/2147483647 no se puede (endurecida). Probamos m/0/1 del vector 2:
|
||||
const d3 = await deriveChildPubkey(d2.pub, d2.chain, 1);
|
||||
console.log("\n=== 6. bech32 — vectores oficiales BIP173 ===");
|
||||
// P2WPKH conocido: pubkey 0279be66... → bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4
|
||||
const pk = Uint8Array.from(Buffer.from("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","hex"));
|
||||
const h = await hash160(pk);
|
||||
check("hash160 de G", hex(h), "751e76e8199196d454941c45d1b3a323f1433bd6");
|
||||
check("dirección P2WPKH", toBech32("bc", Array.from(h)), "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
|
||||
check("misma en testnet", toBech32("tb", Array.from(h)), "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx");
|
||||
|
||||
console.log(`\nRESULTADO: ${pass} correctas, ${fail} incorrectas`);
|
||||
})();
|
||||
@@ -1,63 +0,0 @@
|
||||
const { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses } = require('/tmp/cripto/crypto.js');
|
||||
const hex = a => Array.from(a).map(b=>b.toString(16).padStart(2,'0')).join('');
|
||||
let hallazgos=[];
|
||||
(async () => {
|
||||
|
||||
console.log("=== 7. ¿Se verifica la checksum del xpub? ===");
|
||||
// xpub válido con UN carácter cambiado al final (checksum rota)
|
||||
const bueno = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
|
||||
const malo = bueno.slice(0,-1) + (bueno.slice(-1)==="8" ? "9" : "8");
|
||||
try {
|
||||
const r = B32.decodeBase58(malo);
|
||||
console.log(" ✗ ACEPTA un xpub con checksum inválida — no la comprueba");
|
||||
hallazgos.push({sev:"medio", t:"No se verifica la checksum base58 del xpub", d:"decodeBase58 descarta los 4 bytes de checksum sin comprobarlos. Un xpub mal copiado (un carácter cambiado) se acepta y genera direcciones que no son las del usuario."});
|
||||
} catch(e) { console.log(" ✓ rechaza:", e.message); }
|
||||
|
||||
console.log("\n=== 8. ¿Se valida la longitud del xpub? ===");
|
||||
try {
|
||||
const r = B32.decodeBase58("xpub661MyMwAqRbcFtXgS5sYJ"); // truncado
|
||||
console.log(` ✗ ACEPTA un xpub truncado → ${r.length} bytes (deberían ser 78)`);
|
||||
hallazgos.push({sev:"medio", t:"No se valida la longitud del xpub decodificado", d:"Un xpub truncado produce un array corto; chainCode y pubKey salen vacíos o parciales y la derivación falla de forma confusa o produce basura."});
|
||||
} catch(e){ console.log(" ✓ rechaza:", e.message); }
|
||||
|
||||
console.log("\n=== 9. ¿Se valida que la clave pública esté en la curva? ===");
|
||||
// Punto que NO está en secp256k1: x=1 no tiene y entera para y²=x³+7 → 8 no es residuo
|
||||
try {
|
||||
const falso = Uint8Array.from([2, ...new Array(31).fill(0), 1]); // x=1
|
||||
const pt = SECP.decompress(falso);
|
||||
const enCurva = (pt[1]*pt[1] - (pt[0]**3n + 7n)) % SECP.P === 0n;
|
||||
if (!enCurva) {
|
||||
console.log(" ✗ decompress DEVUELVE un punto que no está en la curva (no valida)");
|
||||
hallazgos.push({sev:"medio", t:"decompress no comprueba que el punto esté en la curva", d:"Con una x que no corresponde a ningún punto de secp256k1, devuelve un par (x,y) inválido en vez de fallar. La derivación seguiría y produciría direcciones sin sentido. Solo alcanzable con un xpub manipulado."});
|
||||
} else console.log(" ✓ el punto resultante sí está en la curva");
|
||||
} catch(e){ console.log(" ✓ rechaza:", e.message); }
|
||||
|
||||
console.log("\n=== 10. Índices endurecidos (no derivables desde xpub) ===");
|
||||
const raw=B32.decodeBase58(bueno), ch=raw.slice(13,45), pb=raw.slice(45,78);
|
||||
try {
|
||||
await deriveChildPubkey(pb, ch, 0x80000000);
|
||||
console.log(" ✗ ACEPTA un índice endurecido — matemáticamente imposible desde una clave pública");
|
||||
hallazgos.push({sev:"bajo", t:"No se rechaza el índice endurecido en deriveChildPubkey", d:"Un índice ≥ 0x80000000 no se puede derivar desde una clave pública. Hoy no se llama nunca con esos valores (deriveAddresses usa 0 y 1), así que no es explotable, pero la función no se defiende sola."});
|
||||
} catch(e){ console.log(" ✓ rechaza:", e.message); }
|
||||
|
||||
console.log("\n=== 11. Consistencia: 100 direcciones seguidas ===");
|
||||
const a = await deriveAddresses(bueno, 100);
|
||||
const todas = [...a.receive, ...a.change];
|
||||
const unicas = new Set(todas);
|
||||
console.log(` direcciones generadas: ${todas.length}, únicas: ${unicas.size}`);
|
||||
console.log(` todas empiezan por bc1q: ${todas.every(x=>x.startsWith("bc1q"))}`);
|
||||
console.log(` longitud correcta (42): ${todas.every(x=>x.length===42)}`);
|
||||
if (unicas.size !== todas.length) hallazgos.push({sev:"alto", t:"Direcciones duplicadas en la derivación", d:"Dos índices distintos producen la misma dirección."});
|
||||
|
||||
console.log("\n=== 12. Detección de red (mainnet vs testnet) ===");
|
||||
for (const [p,esperado] of [["xpub","bc"],["zpub","bc"],["ypub","bc"],["tpub","?"],["vpub","tb"],["upub","tb"]]) {
|
||||
const hrp = p.startsWith("tb")||p.startsWith("u")||p.startsWith("v") ? "tb":"bc";
|
||||
const marca = (p==="tpub" && hrp==="bc") ? " ✗" : " ·";
|
||||
console.log(`${marca} ${p} → ${hrp}`);
|
||||
}
|
||||
hallazgos.push({sev:"medio", t:"tpub (testnet) se trata como mainnet", d:"La detección mira si empieza por 'tb', 'u' o 'v'. Un tpub —el formato más común de testnet— empieza por 't' y NO por 'tb', así que cae en la rama de mainnet y genera direcciones bc1... a partir de claves de testnet."});
|
||||
|
||||
console.log("\n\n════ HALLAZGOS ════");
|
||||
for (const h of hallazgos) console.log(`\n[${h.sev.toUpperCase()}] ${h.t}\n ${h.d}`);
|
||||
if (!hallazgos.length) console.log("ninguno");
|
||||
})();
|
||||
@@ -1,42 +0,0 @@
|
||||
const { B32, SECP, deriveChildPubkey, deriveAddresses } = require('/tmp/cripto/crypto.js');
|
||||
let ok=0, ko=0;
|
||||
const debeFallar = async (nombre, fn) => {
|
||||
try { await fn(); console.log(` ✗ ${nombre} — NO falló`); ko++; }
|
||||
catch(e){ console.log(` ✓ ${nombre}\n → "${e.message}"`); ok++; }
|
||||
};
|
||||
(async () => {
|
||||
const bueno = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
|
||||
|
||||
console.log("=== los cuatro hallazgos, revisados ===\n");
|
||||
await debeFallar("checksum rota (un carácter cambiado)", () =>
|
||||
deriveAddresses(bueno.slice(0,-1) + (bueno.slice(-1)==="8"?"9":"8"), 2));
|
||||
await debeFallar("xpub truncado", () => deriveAddresses("xpub661MyMwAqRbcFtXgS5sYJ", 2));
|
||||
await debeFallar("índice endurecido", async () => {
|
||||
const raw = await B32.decodeBase58Check(bueno);
|
||||
return deriveChildPubkey(raw.slice(45,78), raw.slice(13,45), 0x80000000);
|
||||
});
|
||||
await debeFallar("índice negativo", async () => {
|
||||
const raw = await B32.decodeBase58Check(bueno);
|
||||
return deriveChildPubkey(raw.slice(45,78), raw.slice(13,45), -1);
|
||||
});
|
||||
|
||||
console.log("\n=== el xpub bueno sigue funcionando ===");
|
||||
const a = await deriveAddresses(bueno, 3);
|
||||
console.log(" recepción:", a.receive.join(", "));
|
||||
console.log(" huella:", a.fingerprint);
|
||||
const bien = a.receive.every(x=>x.startsWith("bc1q")&&x.length===42);
|
||||
console.log(` ${bien?'✓':'✗'} formato correcto`); bien?ok++:ko++;
|
||||
|
||||
console.log("\n=== testnet: tpub ahora se detecta bien ===");
|
||||
|
||||
// vector 1 del BIP32 con los bytes de versión de testnet — dato público, no de nadie
|
||||
const tp = "tpubD6NzVbkrYhZ4XgiXtGrdW5XDAPFCL9h7we1vwNCpn8tGbBcgfVYjXyhWo4E1xkh56hjod1RhGjxbaTLV3X4FyWuejifB9jusQ46QzG87VKp";
|
||||
try {
|
||||
const t = await deriveAddresses(tp, 2);
|
||||
const esTb = t.receive.every(x=>x.startsWith("tb1q"));
|
||||
console.log(` ${esTb?'✓':'✗'} genera direcciones de testnet: ${t.receive[0]}`);
|
||||
esTb?ok++:ko++;
|
||||
} catch(e){ console.log(" ✗ falló:", e.message); ko++; }
|
||||
|
||||
console.log(`\nRESULTADO: ${ok} correctas, ${ko} incorrectas`);
|
||||
})();
|
||||
@@ -4,15 +4,8 @@ After=network.target bitcoind.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
# ── AJUSTA ESTAS DOS LÍNEAS ANTES DE INSTALARLO ──────────────────────────
|
||||
# User: el usuario con el que corre tu nodo (el mismo que ejecuta bitcoind
|
||||
# suele ser buena elección). No lo dejes en root.
|
||||
# ExecStart: la ruta donde hayas copiado system-metrics.js.
|
||||
User=TU_USUARIO
|
||||
ExecStart=/usr/bin/node /home/TU_USUARIO/txoko/system-metrics.js
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
User=armg
|
||||
ExecStart=/usr/bin/node /home/armg/txoko/system-metrics.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
|
||||
Reference in New Issue
Block a user