Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4deb711d5 | ||
|
|
af49ea630b | ||
|
|
98621f956e | ||
|
|
d3caaf6186 | ||
|
|
96517894c5 | ||
|
|
0acfb35bf8 | ||
|
|
bb9b92d9b9 | ||
|
|
fd9127880b | ||
|
|
0223bb16ef | ||
|
|
a696e55bef | ||
|
|
8a5efad2ed | ||
|
|
6f721a535c | ||
|
|
020f9318aa | ||
|
|
fd2e38fd45 | ||
|
|
924b9069aa | ||
|
|
e0bc42c34e | ||
|
|
eac6208405 | ||
|
|
fdd80b689a | ||
|
|
f15d82a352 | ||
|
|
826b9cb8e2 | ||
|
|
29e8e184e8 | ||
|
|
2f5cc9a0ec | ||
|
|
5f4f891d1f | ||
|
|
27f23cc967 | ||
|
|
b37b9a125a | ||
|
|
c86134cc24 | ||
|
|
ebc1a8017f | ||
|
|
0ecd54eb1d | ||
|
|
84e16cb3fd | ||
|
|
d6dc8f6a90 | ||
|
|
14a33ed29a | ||
|
|
f5cffa3f8a | ||
|
|
de3ab5cc7e | ||
|
|
55297ecae6 | ||
|
|
2c46ceda31 | ||
|
|
1c25622b0e | ||
|
|
031c55714f | ||
|
|
35c74ea8f2 | ||
|
|
1ddacdf86e | ||
|
|
8ae135e739 | ||
|
|
4354e661c2 | ||
|
|
68ef8f2179 | ||
|
|
26a8650583 | ||
|
|
2b68a7b7c7 | ||
|
|
22ea8b5add | ||
|
|
aa7f23de33 | ||
|
|
0fc9cb8980 | ||
|
|
625fd9e989 | ||
|
|
d0b10d4a99 | ||
|
|
01fe01d39d | ||
|
|
57f9576860 | ||
|
|
dd78d35bdb | ||
|
|
19211af4c8 | ||
|
|
55a8cf456b | ||
|
|
a260e376bb | ||
|
|
37ae3aef70 | ||
|
|
ee67c2164e | ||
|
|
11b74c92f5 | ||
|
|
5aedec0cb3 | ||
|
|
fe2aa0597c | ||
|
|
6626cd1325 | ||
|
|
cb4535c35d | ||
|
|
e71bd08f76 | ||
|
|
8f902ba06e | ||
|
|
e64292531e |
@@ -27,3 +27,6 @@ PRUEBA-PERITAJE.md
|
|||||||
HALLAZGOS-PERITAJE.md
|
HALLAZGOS-PERITAJE.md
|
||||||
MEJORAS.md
|
MEJORAS.md
|
||||||
COHERENCIA.md
|
COHERENCIA.md
|
||||||
|
|
||||||
|
GIT.md
|
||||||
|
REPASO.md
|
||||||
|
|||||||
+106
@@ -10,6 +10,88 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
|
|||||||
- **MENOR** — características nuevas que no rompen lo anterior
|
- **MENOR** — características nuevas que no rompen lo anterior
|
||||||
- **PARCHE** — arreglos de errores
|
- **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
|
## [1.12.0] — 2026-07-27
|
||||||
### Añadido
|
### Añadido
|
||||||
@@ -40,6 +122,30 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
|
|||||||
**Qué puedes hacer** —, que aquí cobra sentido literal: todavía estás a
|
**Qué puedes hacer** —, que aquí cobra sentido literal: todavía estás a
|
||||||
tiempo de cambiar la transacción.
|
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
|
## [1.11.0] — 2026-07-27
|
||||||
### Añadido
|
### Añadido
|
||||||
|
|||||||
@@ -96,6 +96,17 @@ Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo
|
|||||||
- Bandas de privacidad con distinción explícita CERTEZA / PROBABLE / POSIBLE
|
- Bandas de privacidad con distinción explícita CERTEZA / PROBABLE / POSIBLE
|
||||||
- Exportación del informe en JSON y Markdown
|
- 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**
|
**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)
|
- 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
|
- 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
|
||||||
@@ -123,7 +134,7 @@ Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo
|
|||||||
- Conversor sat/BTC/fiat
|
- Conversor sat/BTC/fiat
|
||||||
- Validador de dirección
|
- Validador de dirección
|
||||||
- Detector OP_RETURN
|
- Detector OP_RETURN
|
||||||
- Decodificador PSBT y transacción raw
|
- Decodificador de transacción raw
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -151,66 +162,29 @@ Probado sobre Ubuntu Server 24.04 con HP EliteDesk (i5, 32GB RAM, 2TB NVMe).
|
|||||||
|
|
||||||
## Instalación
|
## Instalación
|
||||||
|
|
||||||
### 1. Clonar el repositorio
|
**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.
|
||||||
|
|
||||||
```bash
|
Resumen de lo que implica:
|
||||||
git clone https://git.bitcointxoko.org/pikaro/txoko-dashboard.git
|
|
||||||
cd txoko-dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Copiar el 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.
|
||||||
|
|
||||||
```bash
|
Un aviso que ahorra disgustos: en nginx, el `alias` del dashboard debe apuntar
|
||||||
cp dashboard.html /var/www/txoko/dashboard.html
|
al **directorio** (con barra final), no al archivo `dashboard.html`. Si apunta
|
||||||
# o donde lo sirvas con nginx
|
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.
|
||||||
|
|
||||||
### 3. Copiar el backend de métricas
|
Cuando termines, abre `http://TU-IP:4080/dashboard/` — **con la barra final** —,
|
||||||
|
pulsa CONFIG e introduce la URL de tu Mempool.
|
||||||
```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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -218,15 +192,22 @@ Introduce la URL de tu Mempool en el modal de configuración y empieza a analiza
|
|||||||
|
|
||||||
```
|
```
|
||||||
txoko-dashboard/
|
txoko-dashboard/
|
||||||
├── dashboard.html # Frontend completo (HTML + CSS + JS en un solo archivo)
|
├── dashboard.html # La aplicación entera (HTML + CSS + JS en un archivo)
|
||||||
├── system-metrics.js # Backend de métricas del nodo (Node.js)
|
├── system-metrics.js # Monitor del nodo (Node.js) — opcional
|
||||||
├── txoko-metrics.service # Servicio systemd
|
├── txoko-metrics.service # Servicio systemd para el monitor
|
||||||
├── README.md
|
├── instalar-fuentes.sh # Descarga IBM Plex al nodo y genera su CSS
|
||||||
├── SETUP.md
|
├── 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
|
├── CHANGELOG.md
|
||||||
|
├── README.md
|
||||||
└── LICENSE
|
└── 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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -1,187 +1,93 @@
|
|||||||
# Cómo subir Txoko a Gitea — paso a paso
|
# Instalación de Txoko Node Dashboard
|
||||||
|
|
||||||
Instrucciones exactas. Copiar y pegar en la terminal del Mac.
|
Guía completa, de principio a fin. Sigue los pasos en orden y comprueba cada
|
||||||
No hace falta entender git para seguir esto.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PASO 1 — Crear el repo en Gitea (una sola vez)
|
## Antes de empezar
|
||||||
|
|
||||||
1. Abre tu instancia de Gitea en el navegador
|
Txoko no habla con la red Bitcoin directamente: se apoya en cosas que ya
|
||||||
2. Clic en el **+** (arriba a la derecha) → "New Repository"
|
tienes montadas. Necesitas:
|
||||||
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) |
|
||||||
|
|
||||||
## PASO 2 — Configurar git en el Mac (una sola vez, si no lo tienes)
|
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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git config --global user.name "tu nombre"
|
curl -s http://127.0.0.1:8999/api/v1/fees/recommended
|
||||||
git config --global user.email "tu@email.com"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Comprueba que git está instalado:
|
Debe devolver un JSON con comisiones. Si no responde, arregla eso primero:
|
||||||
```bash
|
Txoko no puede funcionar sin ello.
|
||||||
git --version
|
|
||||||
```
|
> **Nunca expongas estos puertos a internet.** Txoko está pensado para
|
||||||
Si no está: `brew install git`
|
> accederse por Tailscale, VPN o red local.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PASO 3 — Crear el repo local y primer commit (una sola vez)
|
## Paso 1 — Descargar los archivos
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Crear carpeta del proyecto en el Mac
|
git clone https://git.bitcointxoko.org/pikaro/txoko-dashboard.git
|
||||||
mkdir ~/txoko-dashboard
|
cd 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 4 — Conectar con Gitea y subir (una sola vez)
|
## 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.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sustituye la URL por la de tu repo de Gitea
|
sudo mkdir -p /var/www/txoko
|
||||||
git remote add origin https://gitea.tu-comunidad/tu-usuario/txoko-dashboard.git
|
sudo cp dashboard.html /var/www/txoko/
|
||||||
|
|
||||||
# Subir
|
|
||||||
git push -u origin main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Gitea te pedirá usuario y contraseña la primera vez.
|
Puedes usar otra ruta; solo recuerda cuál es, porque aparece en los pasos 3 y 5.
|
||||||
Si quieres evitar introducirlos cada vez, crea un token en
|
|
||||||
Gitea → Settings → Applications → "Generate Token" y úsalo como contraseña.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PASO 5 — Flujo de trabajo normal (cada vez que yo te dé un archivo nuevo)
|
## 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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/txoko-dashboard
|
sudo mkdir -p /var/www/txoko/vendor
|
||||||
|
cd /var/www/txoko/vendor
|
||||||
# Copiar el archivo actualizado descargado de esta conversación
|
|
||||||
cp ~/Downloads/txoko-dashboard.html ./dashboard.html
|
|
||||||
|
|
||||||
# Ver qué cambió (opcional pero útil)
|
|
||||||
git diff dashboard.html
|
|
||||||
|
|
||||||
# Registrar el cambio con un mensaje descriptivo
|
|
||||||
git add dashboard.html
|
|
||||||
git commit -m "fix: descripción breve de lo que se arregló"
|
|
||||||
|
|
||||||
# Subir a Gitea
|
|
||||||
git push
|
|
||||||
|
|
||||||
# Copiar al nodo (igual que antes)
|
|
||||||
scp dashboard.html armg@100.116.19.86:/home/armg/txoko/dashboard.html
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Mensajes de commit — ejemplos
|
|
||||||
|
|
||||||
El mensaje va después de `-m` y describe QUÉ cambiaste.
|
|
||||||
No tiene que ser perfecto, solo útil para ti en el futuro.
|
|
||||||
|
|
||||||
```
|
|
||||||
"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"
|
|
||||||
```
|
|
||||||
|
|
||||||
Convención (opcional pero ordenada):
|
|
||||||
- `fix:` — corrige algo que no funcionaba bien
|
|
||||||
- `feat:` — añade algo nuevo
|
|
||||||
- `docs:` — solo documentación
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Librerías del frontend (obligatorio)
|
|
||||||
|
|
||||||
El dashboard usa React y Babel. **Se sirven desde tu propio nodo, no desde un
|
|
||||||
CDN.** El motivo es de privacidad, no de comodidad: un CDN externo no ve qué
|
|
||||||
transacciones analizas, pero sí ve que usas Txoko, cuándo y desde qué IP
|
|
||||||
pública — exactamente la clase de metadato que esta herramienta enseña a
|
|
||||||
proteger. Sirviéndolas en local, *"ninguna consulta sale de tu red"* pasa a ser
|
|
||||||
literal, y el dashboard funciona sin conexión a internet.
|
|
||||||
|
|
||||||
Estas librerías **no están en el repo** (son de terceros y pesan ~3 MB). Se
|
|
||||||
descargan una vez, en el nodo:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo mkdir -p /usr/share/nginx/html/vendor
|
|
||||||
cd /usr/share/nginx/html/vendor
|
|
||||||
sudo curl -sSLO https://unpkg.com/react@18.3.1/umd/react.production.min.js
|
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 -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
|
sudo curl -sSL -o babel.min.js https://unpkg.com/@babel/standalone@7.23.10/babel.min.js
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verifica lo que has descargado
|
**Comprueba que has recibido lo que esperabas.** No te fíes: verifícalo.
|
||||||
|
|
||||||
No te fíes: comprueba que los archivos son los que deben ser.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sha256sum *.js
|
sha256sum *.js
|
||||||
@@ -195,118 +101,218 @@ Debe dar exactamente esto:
|
|||||||
d949f1c3687aedadcedac85261865f29b17cd273997e7f6b2bfc53b2f9d4c4dd react.production.min.js
|
d949f1c3687aedadcedac85261865f29b17cd273997e7f6b2bfc53b2f9d4c4dd react.production.min.js
|
||||||
```
|
```
|
||||||
|
|
||||||
Si algún hash no coincide, **no uses esos archivos**: significa que has
|
Si alguno no coincide, **para aquí**: has recibido algo distinto de lo esperado.
|
||||||
recibido algo distinto a lo esperado.
|
|
||||||
|
|
||||||
Las versiones están fijadas a propósito (`18.3.1`, `7.23.10`). Usar un rango
|
Las versiones están fijadas a propósito. Usar un rango como `react@18` dejaría
|
||||||
como `react@18` dejaría que el servidor decidiera qué versión te entrega, y
|
que el servidor decidiera qué versión te entrega, y cambiaría con el tiempo sin
|
||||||
cambiaría con el tiempo sin que te enteres.
|
que te enteres.
|
||||||
|
|
||||||
### Dónde deben quedar los archivos
|
### Las fuentes
|
||||||
|
|
||||||
El dashboard las busca en `vendor/` con **ruta relativa**, es decir, en un
|
|
||||||
subdirectorio junto al propio `dashboard.html`. Así funciona tanto si sirves el
|
|
||||||
dashboard en la raíz como bajo un prefijo, sin tocar nginx.
|
|
||||||
|
|
||||||
Con la configuración típica de Mempool self-hosted:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
location /dashboard {
|
|
||||||
alias /usr/share/nginx/html/;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
…el `dashboard.html` vive en `/usr/share/nginx/html/` y las librerías deben ir
|
|
||||||
en `/usr/share/nginx/html/vendor/` — que es justo donde las deja el comando de
|
|
||||||
arriba.
|
|
||||||
|
|
||||||
**Importante:** abre el dashboard **con la barra final** (`.../dashboard/`, no
|
|
||||||
`.../dashboard`). Sin ella, el navegador resuelve las rutas relativas un nivel
|
|
||||||
por encima y no encuentra las librerías.
|
|
||||||
|
|
||||||
Para comprobar que nginx las sirve bien, lo que importa no es solo el código
|
|
||||||
200 sino el tipo de contenido:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sk -o /dev/null -w '%{http_code} %{content_type}\n' \
|
sudo /ruta/al/repo/instalar-fuentes.sh /var/www/txoko/vendor/fonts
|
||||||
https://localhost:4081/dashboard/vendor/react.production.min.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Debe responder `200 application/javascript`. Si devuelve `200 text/html`,
|
El script descarga IBM Plex (licencia libre OFL), comprueba cada archivo y
|
||||||
nginx está entregando otra cosa (por ejemplo el index de Mempool) y las rutas
|
aborta si algo falla. Son unos 350 KB.
|
||||||
no son las correctas.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTTPS para watch-only (opcional)
|
## Paso 4 — El monitor del sistema (opcional)
|
||||||
|
|
||||||
La función watch-only (derivar tus direcciones desde el xpub) usa la Web Crypto
|
Alimenta la pestaña NODO: CPU, RAM, disco, estado de Bitcoin Core y logs. Sin
|
||||||
API del navegador, que **solo funciona sobre HTTPS**. Si sirves el dashboard por
|
esto el resto de la aplicación funciona igual, solo que esa pestaña queda vacía.
|
||||||
HTTP, watch-only no estará disponible — el resto de la app funciona igual. Las
|
|
||||||
etiquetas BIP-329 no necesitan HTTPS.
|
|
||||||
|
|
||||||
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 autofirmado
|
|
||||||
|
|
||||||
Sustituye la IP por la de tu nodo (Tailscale, local, etc.):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
mkdir -p ~/txoko
|
||||||
-keyout /etc/ssl/private/txoko.key \
|
cp system-metrics.js ~/txoko/
|
||||||
-out /etc/ssl/certs/txoko.crt \
|
|
||||||
-subj "/CN=100.116.19.86" \
|
|
||||||
-addext "subjectAltName=IP:100.116.19.86"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configurar nginx para servir HTTPS
|
Edita `~/txoko/system-metrics.js` y sustituye los dos marcadores por tus
|
||||||
|
credenciales RPC de Bitcoin Core (las de tu `bitcoin.conf`):
|
||||||
|
|
||||||
Añade un bloque `server` que escuche en un puerto con SSL (por ejemplo 4081),
|
```js
|
||||||
apuntando al certificado recién creado e incluyendo la misma configuración que
|
const RPC_USER = "TU_RPC_USER";
|
||||||
tu servidor HTTP:
|
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
|
```nginx
|
||||||
server {
|
# Dashboard — OJO: alias a un DIRECTORIO, con barra final
|
||||||
listen 4081 ssl;
|
location /dashboard/ {
|
||||||
listen [::]:4081 ssl;
|
alias /var/www/txoko/;
|
||||||
server_name _;
|
index dashboard.html;
|
||||||
ssl_certificate /etc/ssl/certs/txoko.crt;
|
try_files $uri $uri/ /dashboard/dashboard.html;
|
||||||
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.)
|
# API de Mempool — ajusta el puerto al de tu instalación
|
||||||
# Si ya tienes esos location en un snippet, basta con incluirlo:
|
location /api/ {
|
||||||
# include /etc/nginx/snippets/tu-config.conf;
|
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;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Si el bloque `location /dashboard` ya viene de un snippet que incluyes, **no lo
|
**El detalle que rompe la instalación:** el `alias` tiene que apuntar al
|
||||||
dupliques** dentro del server SSL: nginx dará error `duplicate location`.
|
**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.
|
||||||
|
|
||||||
Comprueba y recarga:
|
Recarga nginx:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Confiar el certificado la primera vez
|
---
|
||||||
|
|
||||||
Abre `https://TU-IP:4081/dashboard/` en el navegador. Como el certificado es
|
## Paso 6 — Comprobar que funciona
|
||||||
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
|
Antes de abrir el navegador, verifica desde el propio nodo. Ajusta el puerto:
|
||||||
- **Chrome/Brave:** "Configuración avanzada" → "Acceder a TU-IP (no seguro)"
|
|
||||||
|
|
||||||
A partir de ahí el navegador recuerda la excepción y la Web Crypto API queda
|
```bash
|
||||||
disponible, así que watch-only funcionará.
|
# El dashboard llega
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4080/dashboard/
|
||||||
|
|
||||||
> Nota: un certificado autofirmado es perfectamente válido para uso personal en
|
# Y las librerías TAMBIÉN — esto es lo que suele fallar
|
||||||
> tu propia red. El aviso del navegador existe porque no hay una autoridad
|
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
|
||||||
> certificadora de por medio, no porque la conexión sea insegura — el tráfico va
|
http://127.0.0.1:4080/dashboard/vendor/react.production.min.js
|
||||||
> cifrado igual.
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://TU-IP:4080/dashboard/
|
||||||
|
```
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTPS — necesario para el 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.
|
||||||
|
|
||||||
|
Si accedes por Tailscale o red local no tendrás un certificado válido, así que
|
||||||
|
toca generar uno autofirmado.
|
||||||
|
|
||||||
|
### 1. Generar el certificado
|
||||||
|
|
||||||
|
Sustituye la IP por la de tu nodo:
|
||||||
|
|
||||||
|
```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"
|
||||||
|
```
|
||||||
|
|
||||||
|
El `subjectAltName` no es opcional: sin él los navegadores modernos rechazan el
|
||||||
|
certificado aunque el `CN` sea correcto.
|
||||||
|
|
||||||
|
### 2. Servirlo en nginx
|
||||||
|
|
||||||
|
Duplica tu bloque `server` en otro puerto (4081 en este ejemplo) añadiendo:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
listen 4081 ssl;
|
||||||
|
ssl_certificate /etc/ssl/certs/txoko.crt;
|
||||||
|
ssl_certificate_key /etc/ssl/private/txoko.key;
|
||||||
|
```
|
||||||
|
|
||||||
|
Los `location` son los mismos del paso 5. Recarga con `sudo nginx -t &&
|
||||||
|
sudo systemctl reload nginx`.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
sudo cp dashboard.html /var/www/txoko/
|
||||||
|
```
|
||||||
|
|
||||||
|
Y recarga el navegador con **Ctrl+Shift+R** (o Cmd+Shift+R en Mac) para saltarte
|
||||||
|
la caché.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problemas frecuentes
|
||||||
|
|
||||||
|
| 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" |
|
||||||
|
|||||||
+184
-24
@@ -967,18 +967,47 @@
|
|||||||
// ─── Electrum ──────────────────────────────────────────────────
|
// ─── Electrum ──────────────────────────────────────────────────
|
||||||
// Guardia: Electrum usa P2WPKH por defecto. Si no hay inputs P2WPKH,
|
// Guardia: Electrum usa P2WPKH por defecto. Si no hay inputs P2WPKH,
|
||||||
// no tiene sentido puntuar Electrum (evita falsos positivos en P2TR/P2PKH).
|
// no tiene sentido puntuar Electrum (evita falsos positivos en P2TR/P2PKH).
|
||||||
|
// Antes se puntuaba Electrum por AUSENCIA de rasgos: locktime=0, sin
|
||||||
|
// RBF, sequence por defecto. Eso es exactamente al revés — Electrum
|
||||||
|
// moderno (4.x, desde 2020) pone locktime a la altura actual para
|
||||||
|
// protegerse del fee sniping y señaliza RBF por defecto. Lo que aquellas
|
||||||
|
// señales describen no es un wallet concreto, sino software que no
|
||||||
|
// configura nada: un script hecho a medida.
|
||||||
|
//
|
||||||
|
// El fallo salió analizando la transacción de un robo real: la app la
|
||||||
|
// atribuía a "Electrum (PROBABLE)" cuando sus rasgos eran justo los
|
||||||
|
// contrarios a los de Electrum. Un silencio leído como una afirmación.
|
||||||
if (allP2wpkh) {
|
if (allP2wpkh) {
|
||||||
let score = 2, sigs = ["inputs P2WPKH uniformes"]; // incluido por la guardia
|
let score = 0, sigs = [];
|
||||||
if (locktime === 0) { score+=1; sigs.push("locktime=0"); }
|
if (locktimeIsBlock) { score+=3; sigs.push("locktime=altura actual (anti-fee-sniping)"); }
|
||||||
if (!hasRbfOptin) { score+=1; sigs.push("sin RBF"); }
|
if (hasRbfOptin) { score+=2; sigs.push("RBF señalizado"); }
|
||||||
if (allInputs.every(v=>v.sequence===0xffffffff)) { score+=2; sigs.push("sequence=0xFFFFFFFF"); }
|
|
||||||
const allOutSameTypeAsIn = allOutputs.length > 0 &&
|
const allOutSameTypeAsIn = allOutputs.length > 0 &&
|
||||||
allOutputs.every(o => o.scriptpubkey_type === inTypes[0]);
|
allOutputs.every(o => o.scriptpubkey_type === inTypes[0]);
|
||||||
if (allOutSameTypeAsIn) { score+=1; sigs.push("outputs del mismo tipo que inputs"); }
|
if (allOutSameTypeAsIn) { score+=1; sigs.push("outputs del mismo tipo que inputs"); }
|
||||||
if (allOutputs.length === 2) { score+=1; sigs.push("2 outputs"); }
|
if (allOutputs.length === 2) { score+=1; sigs.push("2 outputs"); }
|
||||||
|
if (score >= 2) sigs.unshift("inputs P2WPKH uniformes");
|
||||||
|
// Hace falta al menos una señal POSITIVA fuerte, no solo ausencias.
|
||||||
if (score >= 5) results.push({ name:"Electrum", score, confidence: score>=6?"PROBABLE":"POSIBLE", signals:sigs });
|
if (score >= 5) results.push({ name:"Electrum", score, confidence: score>=6?"PROBABLE":"POSIBLE", signals:sigs });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Sin rasgos distintivos ────────────────────────────────────
|
||||||
|
// Una transacción sin locktime, sin RBF y con sequence al máximo no
|
||||||
|
// lleva ninguna de las marcas que dejan los monederos actuales. Decirlo
|
||||||
|
// es más honesto que forzar un nombre: es lo que produce una librería
|
||||||
|
// en crudo o un script propio, y también algún wallet antiguo.
|
||||||
|
{
|
||||||
|
const sinLocktime = locktime === 0;
|
||||||
|
const sinRbf = !hasRbfOptin;
|
||||||
|
const seqPorDefecto = allInputs.length > 0 && allInputs.every(v=>v.sequence===0xffffffff);
|
||||||
|
if (sinLocktime && sinRbf && seqPorDefecto && results.length === 0) {
|
||||||
|
results.push({
|
||||||
|
name: "sin rasgos distintivos", score: 3, confidence: "POSIBLE",
|
||||||
|
signals: ["locktime=0", "sin RBF", "sequence=0xFFFFFFFF"],
|
||||||
|
note: "No lleva las marcas que dejan los monederos habituales. Compatible con una librería usada en crudo, un script propio o software antiguo — pero no identifica a ninguno en concreto.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── BlueWallet / mobile ───────────────────────────────────────
|
// ─── BlueWallet / mobile ───────────────────────────────────────
|
||||||
// Corregido: eliminado doble-conteo de "1 input P2WPKH" (antes sumaba 4 pts solo).
|
// Corregido: eliminado doble-conteo de "1 input P2WPKH" (antes sumaba 4 pts solo).
|
||||||
// Umbral subido a 6 para no dispararse con cualquier tx simple genérica.
|
// Umbral subido a 6 para no dispararse con cualquier tx simple genérica.
|
||||||
@@ -1055,8 +1084,27 @@
|
|||||||
for (const c of str) { if (c === "1") leadingZeros++; else break; }
|
for (const c of str) { if (c === "1") leadingZeros++; else break; }
|
||||||
const full = new Uint8Array(leadingZeros + body.length);
|
const full = new Uint8Array(leadingZeros + body.length);
|
||||||
full.set(body, leadingZeros);
|
full.set(body, leadingZeros);
|
||||||
// full = 82 bytes (78 payload + 4 checksum); devolvemos el payload
|
return full; // completo: payload + 4 bytes de checksum
|
||||||
return full.slice(0, full.length - 4);
|
},
|
||||||
|
|
||||||
|
// Decodifica y COMPRUEBA la checksum. Es async porque el hash lo hace
|
||||||
|
// Web Crypto. Sin esta comprobación, un xpub con un solo carácter mal
|
||||||
|
// copiado se acepta sin protestar y genera direcciones que no son las
|
||||||
|
// del usuario — que entonces vería su cartera "sin actividad" y se
|
||||||
|
// quedaría tranquilo. Un falso negativo silencioso, justo lo que este
|
||||||
|
// proyecto evita en todo lo demás.
|
||||||
|
decodeBase58Check: async (str) => {
|
||||||
|
const full = B32.decodeBase58(str);
|
||||||
|
if (full.length < 5) throw new Error("Cadena demasiado corta para ser una clave extendida válida.");
|
||||||
|
const payload = full.slice(0, full.length - 4);
|
||||||
|
const checksum = full.slice(full.length - 4);
|
||||||
|
const h = await B32.sha256d(payload);
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
if (h[i] !== checksum[i]) {
|
||||||
|
throw new Error("La clave no es válida: la suma de verificación no cuadra. Suele ser un carácter mal copiado — revísala y pégala entera.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
},
|
},
|
||||||
|
|
||||||
// SHA256 doble (para checksum base58)
|
// SHA256 doble (para checksum base58)
|
||||||
@@ -1153,7 +1201,13 @@
|
|||||||
|
|
||||||
// Derivación BIP32 de clave pública hija (solo clave pública, sin hardened)
|
// Derivación BIP32 de clave pública hija (solo clave pública, sin hardened)
|
||||||
const deriveChildPubkey = async (parentPub, parentChain, index) => {
|
const deriveChildPubkey = async (parentPub, parentChain, index) => {
|
||||||
const indexBytes = B32.u32be(index); // index < 0x80000000 (no hardened)
|
// Los índices endurecidos (≥ 2³¹) NO se pueden derivar desde una clave
|
||||||
|
// pública: hace falta la privada. Hoy solo se llama con 0 y 1, así que
|
||||||
|
// no es alcanzable, pero una función criptográfica debe defenderse sola.
|
||||||
|
if (!Number.isInteger(index) || index < 0 || index >= 0x80000000) {
|
||||||
|
throw new Error("Índice de derivación inválido: desde una clave pública no se pueden derivar índices endurecidos.");
|
||||||
|
}
|
||||||
|
const indexBytes = B32.u32be(index);
|
||||||
const data = B32.concat(parentPub, indexBytes);
|
const data = B32.concat(parentPub, indexBytes);
|
||||||
const I = await B32.hmac512(parentChain, data);
|
const I = await B32.hmac512(parentChain, data);
|
||||||
const IL = I.slice(0, 32);
|
const IL = I.slice(0, 32);
|
||||||
@@ -1272,17 +1326,42 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Derivar N direcciones desde xpub (rama 0=recepción, 1=cambio)
|
// Derivar N direcciones desde xpub (rama 0=recepción, 1=cambio)
|
||||||
|
// Redes por bytes de versión, no por el prefijo del texto. Mirar las
|
||||||
|
// primeras letras parece equivalente y no lo es: un `tpub` —el formato más
|
||||||
|
// habitual de testnet— empieza por "t" pero no por "tb", así que una
|
||||||
|
// comprobación textual lo confunde con mainnet y genera direcciones bc1…
|
||||||
|
// a partir de claves de testnet. Los bytes de versión son inequívocos.
|
||||||
|
const XPUB_VERSIONS = {
|
||||||
|
"0488b21e": { red:"bc", tipo:"xpub" }, "049d7cb2": { red:"bc", tipo:"ypub" },
|
||||||
|
"04b24746": { red:"bc", tipo:"zpub" }, "0295b43f": { red:"bc", tipo:"Ypub" },
|
||||||
|
"02aa7ed3": { red:"bc", tipo:"Zpub" },
|
||||||
|
"043587cf": { red:"tb", tipo:"tpub" }, "044a5262": { red:"tb", tipo:"upub" },
|
||||||
|
"045f1cf6": { red:"tb", tipo:"vpub" }, "024289ef": { red:"tb", tipo:"Upub" },
|
||||||
|
"02575483": { red:"tb", tipo:"Vpub" },
|
||||||
|
};
|
||||||
|
|
||||||
const deriveAddresses = async (xpubStr, count=20) => {
|
const deriveAddresses = async (xpubStr, count=20) => {
|
||||||
// Decodificar xpub/zpub
|
// decodeBase58Check comprueba la suma de verificación: un carácter mal
|
||||||
const raw = B32.decodeBase58(xpubStr.trim());
|
// copiado se detecta aquí y no acaba generando direcciones ajenas.
|
||||||
|
const raw = await B32.decodeBase58Check(xpubStr.trim());
|
||||||
// raw[0..3]=version, [4]=depth, [5..8]=fingerprint, [9..12]=childIndex
|
// raw[0..3]=version, [4]=depth, [5..8]=fingerprint, [9..12]=childIndex
|
||||||
// [13..44]=chainCode, [45..77]=pubKey
|
// [13..44]=chainCode, [45..77]=pubKey
|
||||||
|
if (raw.length !== 78) {
|
||||||
|
throw new Error(`La clave extendida debería ocupar 78 bytes y ocupa ${raw.length}. Parece incompleta o de un formato que no reconozco.`);
|
||||||
|
}
|
||||||
|
const version = Array.from(raw.slice(0,4)).map(b=>b.toString(16).padStart(2,"0")).join("");
|
||||||
|
const info = XPUB_VERSIONS[version];
|
||||||
|
if (!info) throw new Error(`Formato de clave extendida no reconocido (versión ${version}).`);
|
||||||
|
|
||||||
const chainCode = raw.slice(13, 45);
|
const chainCode = raw.slice(13, 45);
|
||||||
const pubKey = raw.slice(45, 78);
|
const pubKey = raw.slice(45, 78);
|
||||||
|
if (pubKey[0] !== 0x02 && pubKey[0] !== 0x03) {
|
||||||
|
throw new Error("La clave pública que contiene no tiene un formato comprimido válido.");
|
||||||
|
}
|
||||||
// Parent fingerprint (bytes 5-8): lo que Sparrow muestra junto al keystore
|
// Parent fingerprint (bytes 5-8): lo que Sparrow muestra junto al keystore
|
||||||
const fingerprint = Array.from(raw.slice(5, 9)).map(b => b.toString(16).padStart(2,"0")).join("");
|
const fingerprint = Array.from(raw.slice(5, 9)).map(b => b.toString(16).padStart(2,"0")).join("");
|
||||||
|
|
||||||
const hrp = xpubStr.startsWith("tb") || xpubStr.startsWith("u") || xpubStr.startsWith("v") ? "tb" : "bc";
|
const hrp = info.red;
|
||||||
|
|
||||||
const result = { receive: [], change: [], fingerprint };
|
const result = { receive: [], change: [], fingerprint };
|
||||||
for (const [branch, label] of [[0,"receive"],[1,"change"]]) {
|
for (const [branch, label] of [[0,"receive"],[1,"change"]]) {
|
||||||
@@ -1581,6 +1660,48 @@
|
|||||||
});
|
});
|
||||||
if (hasInputReuse) deductions += weights.input_reuse;
|
if (hasInputReuse) deductions += weights.input_reuse;
|
||||||
|
|
||||||
|
// ── 1b. Vinculación de direcciones al gastarlas juntas (CIOH) ──────
|
||||||
|
// Faltaba, y es el hueco más grande que tenía el analizador: se 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 ocurre el daño irreversible.
|
||||||
|
//
|
||||||
|
// Gastar N direcciones distintas en una misma transacción las enlaza
|
||||||
|
// para siempre a ojos de cualquiera. Es la heurística CIOH, la más
|
||||||
|
// fiable del análisis de cadena, y aquí no se aplicaba a la propia
|
||||||
|
// transacción que se está mirando — solo al conjunto del wallet.
|
||||||
|
//
|
||||||
|
// Se detectó analizando una consolidación real de 491 direcciones: la
|
||||||
|
// app la calificaba de privacidad ALTA porque el cambio no se
|
||||||
|
// distinguía, sin mencionar que 491 direcciones acababan de quedar
|
||||||
|
// vinculadas en público.
|
||||||
|
//
|
||||||
|
// No aplica en CoinJoin: ahí las entradas son de personas distintas y
|
||||||
|
// esa es justamente la excepción clásica a CIOH.
|
||||||
|
const nVinculadas = uniqueInputAddrs.size;
|
||||||
|
const hayVinculacion = nVinculadas > 1 && !likelyCJ;
|
||||||
|
// Escalonado: consolidar dos monedas es rutina; juntar decenas es otra
|
||||||
|
// cosa. El peso sube con el número de direcciones que quedan atadas.
|
||||||
|
const pesoVinculacion = !hayVinculacion ? 0
|
||||||
|
: nVinculadas >= 20 ? 30
|
||||||
|
: nVinculadas >= 10 ? 22
|
||||||
|
: nVinculadas >= 5 ? 15
|
||||||
|
: 8;
|
||||||
|
checks.push({
|
||||||
|
id:"input_linkage", label:"Direcciones que quedan vinculadas entre sí", certainty:"CERTEZA",
|
||||||
|
pass: !hayVinculacion,
|
||||||
|
informational: likelyCJ && nVinculadas > 1,
|
||||||
|
actionability: "evitable",
|
||||||
|
detail: !hayVinculacion
|
||||||
|
? (likelyCJ && nVinculadas > 1
|
||||||
|
? `Se gastan ${nVinculadas} direcciones distintas, pero en un CoinJoin las entradas son de participantes diferentes: la vinculación por CIOH no aplica aquí. De hecho, romperla es el objetivo de la mezcla.`
|
||||||
|
: "Solo se gasta una dirección, así que esta transacción no enlaza direcciones entre sí.")
|
||||||
|
: `Hecho (certeza): esta transacción gasta ${nVinculadas} direcciones distintas a la vez. Interpretación (certeza para un observador): quien mira la cadena asume que las ${nVinculadas} pertenecen al mismo dueño, porque hizo falta la clave de todas para firmar. Consecuencia: quedan enlazadas de forma permanente y pública. Si alguna de ellas se asocia alguna vez a una identidad, arrastra a las demás — y esto no se puede deshacer.`,
|
||||||
|
didactic: "Es la heurística CIOH (common input ownership), la más fiable que existe en el análisis de cadena y la primera que aplica cualquiera que observe. Cada vez que gastas varias monedas juntas, le dices al mundo que son de la misma persona. Consolidar cuando las comisiones están baratas es tentador y sale caro en privacidad: media docena de consolidaciones descuidadas bastan para reconstruir un wallet entero. La forma de evitarlo es gastar monedas de una en una cuando su origen no debe relacionarse — que es justo lo contrario de lo que invita a hacer una comisión baja.",
|
||||||
|
penalty: pesoVinculacion,
|
||||||
|
});
|
||||||
|
if (hayVinculacion) deductions += pesoVinculacion;
|
||||||
|
|
||||||
// ── 2. Mezcla de tipos en inputs ──────────────────────────────────
|
// ── 2. Mezcla de tipos en inputs ──────────────────────────────────
|
||||||
const inTypes = [...new Set(tx.vin.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean))];
|
const inTypes = [...new Set(tx.vin.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean))];
|
||||||
const outTypes = [...new Set(tx.vout.map(v=>v.scriptpubkey_type).filter(Boolean))];
|
const outTypes = [...new Set(tx.vout.map(v=>v.scriptpubkey_type).filter(Boolean))];
|
||||||
@@ -1834,20 +1955,25 @@
|
|||||||
checks.push({
|
checks.push({
|
||||||
id:"wallet_fingerprint", label:"Fingerprinting de wallet",
|
id:"wallet_fingerprint", label:"Fingerprinting de wallet",
|
||||||
certainty: detectedWallets[0]?.confidence||"POSIBLE",
|
certainty: detectedWallets[0]?.confidence||"POSIBLE",
|
||||||
pass: likelyCJ ? true : detectedWallets.length===0,
|
pass: likelyCJ ? true : !detectedWallets.some(w => w.name !== "sin rasgos distintivos"),
|
||||||
informational: likelyCJ && detectedWallets.length > 0,
|
informational: (likelyCJ && detectedWallets.length > 0) ||
|
||||||
|
(detectedWallets.length > 0 && !detectedWallets.some(w => w.name !== "sin rasgos distintivos")),
|
||||||
actionability: likelyCJ ? null : "wallet",
|
actionability: likelyCJ ? null : "wallet",
|
||||||
detail: detectedWallets.length > 0
|
detail: detectedWallets.length > 0
|
||||||
? (likelyCJ
|
? (likelyCJ
|
||||||
? `En un CoinJoin identificar el software es informativo, no un problema. ${detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}`).join(" · ")}`
|
? `En un CoinJoin identificar el software es informativo, no un problema. ${detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}`).join(" · ")}`
|
||||||
: detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}`).join(" · "))
|
: detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}${w.note?` — ${w.note}`:""}`).join(" · "))
|
||||||
: "No se detecta un patrón de wallet específico con suficiente certeza.",
|
: "No se detecta un patrón de wallet específico con suficiente certeza.",
|
||||||
didactic: "El objetivo no es ser invisible sino ser indistinguible de millones de usuarios del mismo wallet. Un fingerprint de Bitcoin Core lo comparten millones de transacciones — no revela nada útil. Un fingerprint de Exodus o un wallet minoritario pertenece a un conjunto mucho menor y es más revelador. En un CoinJoin, el tipo de mezcla ya sugiere el software usado.",
|
didactic: "El objetivo no es ser invisible sino ser indistinguible de millones de usuarios del mismo wallet. Un fingerprint de Bitcoin Core lo comparten millones de transacciones — no revela nada útil. Un fingerprint de Exodus o un wallet minoritario pertenece a un conjunto mucho menor y es más revelador. En un CoinJoin, el tipo de mezcla ya sugiere el software usado.",
|
||||||
penalty: likelyCJ ? 0 : weights.wallet_fingerprint,
|
penalty: likelyCJ ? 0 : weights.wallet_fingerprint,
|
||||||
});
|
});
|
||||||
// En CoinJoin no penaliza (identificar el wallet es esperado, no un fallo).
|
// En CoinJoin no penaliza (identificar el wallet es esperado, no un fallo).
|
||||||
// Fuera de CoinJoin, solo penaliza si se detectó un wallet estructural real.
|
// Fuera de CoinJoin, solo penaliza si se identificó un software concreto:
|
||||||
if (!likelyCJ && detectedWallets.length > 0) deductions += weights.wallet_fingerprint;
|
// "sin rasgos distintivos" es lo contrario de una huella — significa que
|
||||||
|
// la transacción no se puede atribuir a ningún monedero, que si acaso
|
||||||
|
// juega a favor. Penalizarlo sería cobrar por no dejar rastro.
|
||||||
|
const huellaReal = detectedWallets.filter(w => w.name !== "sin rasgos distintivos");
|
||||||
|
if (!likelyCJ && huellaReal.length > 0) deductions += weights.wallet_fingerprint;
|
||||||
|
|
||||||
// ── 13. PayJoin — informativo ─────────────────────────────────────
|
// ── 13. PayJoin — informativo ─────────────────────────────────────
|
||||||
checks.push({
|
checks.push({
|
||||||
@@ -2787,11 +2913,22 @@
|
|||||||
<div key={i} style={{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"5px 0",borderBottom:i<4?`1px solid ${C.border}`:"none"}}>
|
<div key={i} style={{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"5px 0",borderBottom:i<4?`1px solid ${C.border}`:"none"}}>
|
||||||
<span style={{fontSize:"0.7rem",fontFamily:"monospace",color:C.blue,width:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{p.command}</span>
|
<span style={{fontSize:"0.7rem",fontFamily:"monospace",color:C.blue,width:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{p.command}</span>
|
||||||
<div style={{display:"flex",gap:8}}>
|
<div style={{display:"flex",gap:8}}>
|
||||||
<Badge color={C.amber}>CPU {p.cpu}%</Badge>
|
{/* Se muestra el % del sistema (lo que de verdad se
|
||||||
|
quiere saber: cuánto de la máquina se lleva). El
|
||||||
|
% de un núcleo va en el tooltip, para comparar con
|
||||||
|
top si hace falta. */}
|
||||||
|
<Badge color={p.cpuSistema>25?C.red:p.cpuSistema>10?C.amber:C.t2}>
|
||||||
|
<span title={p.cpu!=null?`${p.cpu}% de un núcleo · media desde que arrancó según ps: ${p.cpuMedia}%`:""}>
|
||||||
|
CPU {p.cpuSistema!=null?p.cpuSistema:p.cpu}%
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
<Badge color={p.mem>30?C.red:p.mem>15?C.amber:C.t2}>RAM {p.mem}%</Badge>
|
<Badge color={p.mem>30?C.red:p.mem>15?C.amber:C.t2}>RAM {p.mem}%</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<div style={{fontSize:"0.55rem",color:C.t3,fontFamily:"monospace",marginTop:8,lineHeight:1.5}}>
|
||||||
|
CPU sobre el total de la máquina ({sysData.cpu?.count||"?"} núcleos), medida en una ventana de 500 ms — no es la media desde el arranque que muestra <code>ps</code>, que engaña porque un proceso que trabajó mucho hace días sigue apareciendo alto.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3521,13 +3658,15 @@
|
|||||||
if(sorted.length>MAX_UTXOS) setErr(`Mostrando ${MAX_UTXOS} de ${sorted.length} UTXOs — se muestran los de mayor valor.`);
|
if(sorted.length>MAX_UTXOS) setErr(`Mostrando ${MAX_UTXOS} de ${sorted.length} UTXOs — se muestran los de mayor valor.`);
|
||||||
|
|
||||||
// Cargar txs de origen para contexto (máx 10, las más grandes)
|
// Cargar txs de origen para contexto (máx 10, las más grandes)
|
||||||
|
// Vía get() y no fetch directo: así pasa por la caché compartida.
|
||||||
|
// Las transacciones confirmadas son inmutables y se guardan toda la
|
||||||
|
// sesión, de modo que volver a este UTXO Map (o analizar una de estas
|
||||||
|
// txs en otra pestaña) no cuesta ni una petición más al nodo.
|
||||||
const cache={};
|
const cache={};
|
||||||
const toFetch=limited.slice(0,10);
|
const toFetch=limited.slice(0,10);
|
||||||
await Promise.all(toFetch.map(async u=>{
|
await Promise.all(toFetch.map(async u=>{
|
||||||
try{
|
const t = await get(`/api/tx/${u.txid}`, null);
|
||||||
const res=await fetchWithTimeout(`${base}/api/tx/${u.txid}`);
|
if (t) cache[u.txid] = t;
|
||||||
if(res.ok) cache[u.txid]=await res.json();
|
|
||||||
}catch{}
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setTxCache(cache);
|
setTxCache(cache);
|
||||||
@@ -3750,6 +3889,13 @@
|
|||||||
const funded = stats.funded_txo_sum || 0;
|
const funded = stats.funded_txo_sum || 0;
|
||||||
const spent = stats.spent_txo_sum || 0;
|
const spent = stats.spent_txo_sum || 0;
|
||||||
const txCount = stats.tx_count || 0;
|
const txCount = stats.tx_count || 0;
|
||||||
|
// El backend de Mempool sobre Fulcrum devuelve los importes a cero en
|
||||||
|
// direcciones con mucho historial, aunque el contador de transacciones
|
||||||
|
// sí venga bien. Una dirección con transacciones pero sin nada recibido
|
||||||
|
// es imposible en la cadena: si se da, los datos están incompletos.
|
||||||
|
// Sin esta comprobación, el perfil concluye "no es hot wallet" cuando en
|
||||||
|
// realidad no ha podido mirarlo — otro silencio leído como respuesta.
|
||||||
|
const statsIncompletas = txCount > 0 && (stats.funded_txo_count || 0) === 0;
|
||||||
const residual = funded - spent;
|
const residual = funded - spent;
|
||||||
const residualRatio = funded > 0 ? residual / funded : 0;
|
const residualRatio = funded > 0 ? residual / funded : 0;
|
||||||
|
|
||||||
@@ -3759,7 +3905,9 @@
|
|||||||
// Señal de flujo: saldo residual casi nulo + volumen y tx_count altos ->
|
// Señal de flujo: saldo residual casi nulo + volumen y tx_count altos ->
|
||||||
// la dirección funciona como paso de caudal, no como ahorro (típico de
|
// la dirección funciona como paso de caudal, no como ahorro (típico de
|
||||||
// hot wallet de exchange). Lo contrario -> retención típica de uso personal.
|
// hot wallet de exchange). Lo contrario -> retención típica de uso personal.
|
||||||
if (funded > 0 && residualRatio < 0.05 && txCount >= 20) {
|
if (statsIncompletas) {
|
||||||
|
signals.push(`el nodo no devolvió los importes de esta dirección (${txCount} transacciones registradas, 0 recibido — imposible en la cadena). La señal de flujo no se ha podido evaluar: no es que se haya descartado, es que no se ha podido mirar`);
|
||||||
|
} else if (funded > 0 && residualRatio < 0.05 && txCount >= 20) {
|
||||||
hotScore += 2;
|
hotScore += 2;
|
||||||
signals.push(`saldo residual ≈0 (${(residualRatio*100).toFixed(1)}% de lo recibido) con ${txCount} transacciones — patrón de flujo, no de ahorro`);
|
signals.push(`saldo residual ≈0 (${(residualRatio*100).toFixed(1)}% de lo recibido) con ${txCount} transacciones — patrón de flujo, no de ahorro`);
|
||||||
} else if (funded > 0 && residualRatio > 0.3 && txCount < 10) {
|
} else if (funded > 0 && residualRatio > 0.3 && txCount < 10) {
|
||||||
@@ -5734,18 +5882,30 @@
|
|||||||
const m=addr.mempool_stats||{funded_txo_sum:0,spent_txo_sum:0};
|
const m=addr.mempool_stats||{funded_txo_sum:0,spent_txo_sum:0};
|
||||||
const balance=c.funded_txo_sum-c.spent_txo_sum;
|
const balance=c.funded_txo_sum-c.spent_txo_sum;
|
||||||
const unconf=(m.funded_txo_sum||0)-(m.spent_txo_sum||0);
|
const unconf=(m.funded_txo_sum||0)-(m.spent_txo_sum||0);
|
||||||
|
// Con Fulcrum por debajo, el backend devuelve los importes a cero en
|
||||||
|
// direcciones con mucho historial, aunque el contador de transacciones
|
||||||
|
// venga bien. Tener transacciones y no haber recibido nada es imposible
|
||||||
|
// en la cadena, así que cuando se da, los importes no son reales: son un
|
||||||
|
// hueco. Mostrar "Balance 0" tal cual sería mentir con seguridad.
|
||||||
|
const importesIncompletos = c.tx_count > 0 && (c.funded_txo_count||0) === 0;
|
||||||
|
const noDisponible = "no disponible";
|
||||||
return (
|
return (
|
||||||
<div style={{display:"flex",flexDirection:"column",gap:10}}>
|
<div style={{display:"flex",flexDirection:"column",gap:10}}>
|
||||||
<Card glow={C.green}>
|
<Card glow={C.green}>
|
||||||
<div style={{marginBottom:12}}><div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>DIRECCIÓN</div><div style={{fontSize:"0.72rem",fontFamily:"monospace",color:C.green,wordBreak:"break-all"}}>{addr.address}</div></div>
|
<div style={{marginBottom:12}}><div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>DIRECCIÓN</div><div style={{fontSize:"0.72rem",fontFamily:"monospace",color:C.green,wordBreak:"break-all"}}>{addr.address}</div></div>
|
||||||
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",gap:12,paddingTop:12,borderTop:`1px solid ${C.border}`}}>
|
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",gap:12,paddingTop:12,borderTop:`1px solid ${C.border}`}}>
|
||||||
<Tag label="Balance" value={fmt.btc(balance)} color={C.green}/>
|
<Tag label="Balance" value={importesIncompletos?noDisponible:fmt.btc(balance)} color={importesIncompletos?C.t3:C.green}/>
|
||||||
<Tag label="No confirmado" value={unconf!==0?fmt.btc(unconf):"0"} color={unconf>0?C.amber:C.t2}/>
|
<Tag label="No confirmado" value={unconf!==0?fmt.btc(unconf):"0"} color={unconf>0?C.amber:C.t2}/>
|
||||||
<Tag label="Total recibido" value={fmt.btc(c.funded_txo_sum)} color={C.blue}/>
|
<Tag label="Total recibido" value={importesIncompletos?noDisponible:fmt.btc(c.funded_txo_sum)} color={importesIncompletos?C.t3:C.blue}/>
|
||||||
<Tag label="Total enviado" value={fmt.btc(c.spent_txo_sum)} color={C.red}/>
|
<Tag label="Total enviado" value={importesIncompletos?noDisponible:fmt.btc(c.spent_txo_sum)} color={importesIncompletos?C.t3:C.red}/>
|
||||||
<Tag label="Transacciones" value={fmt.num(c.tx_count)} color={C.t1}/>
|
<Tag label="Transacciones" value={fmt.num(c.tx_count)} color={C.t1}/>
|
||||||
<Tag label="UTXOs" value={fmt.num(addr.utxos?addr.utxos.length:0)} color={C.amber}/>
|
<Tag label="UTXOs" value={fmt.num(addr.utxos?addr.utxos.length:0)} color={C.amber}/>
|
||||||
</div>
|
</div>
|
||||||
|
{importesIncompletos&&(
|
||||||
|
<div style={{marginTop:12,padding:"9px 12px",background:C.amberMuted,border:`1px solid ${C.amber}40`,borderRadius:6,fontSize:"0.63rem",color:C.t1,fontFamily:"monospace",lineHeight:1.6}}>
|
||||||
|
<strong style={{color:C.amber}}>Importes no disponibles.</strong> El nodo informa de {fmt.num(c.tx_count)} transacciones pero devuelve cero recibido, y eso no puede ocurrir en la cadena. Es una limitación conocida del backend de Mempool sobre Fulcrum con direcciones de mucho historial. Los importes se ocultan en vez de mostrarse a cero, porque un cero aquí sería un dato falso. El número de transacciones y la lista de abajo sí son correctos.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
{analysis&&<PrivacyLab analysis={analysis}/>}
|
{analysis&&<PrivacyLab analysis={analysis}/>}
|
||||||
{addr.utxos&&addr.utxos.length>0&&<Card><div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:10}}>UTXOs</div>{addr.utxos.slice(0,6).map((u,i)=><div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"6px 0",borderBottom:i<Math.min(addr.utxos.length,6)-1?`1px solid ${C.border}`:"none"}}><span style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue}}>{fmt.hash(u.txid)}:{u.vout}</span><div style={{display:"flex",gap:6}}><Badge color={C.green}>{fmt.btc(u.value)}</Badge><Badge color={u.status&&u.status.confirmed?C.t2:C.amber}>{u.status&&u.status.confirmed?`#${fmt.num(u.status.block_height)}`:"mempool"}</Badge></div></div>)}</Card>}
|
{addr.utxos&&addr.utxos.length>0&&<Card><div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:10}}>UTXOs</div>{addr.utxos.slice(0,6).map((u,i)=><div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"6px 0",borderBottom:i<Math.min(addr.utxos.length,6)-1?`1px solid ${C.border}`:"none"}}><span style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue}}>{fmt.hash(u.txid)}:{u.vout}</span><div style={{display:"flex",gap:6}}><Badge color={C.green}>{fmt.btc(u.value)}</Badge><Badge color={u.status&&u.status.confirmed?C.t2:C.amber}>{u.status&&u.status.confirmed?`#${fmt.num(u.status.block_height)}`:"mempool"}</Badge></div></div>)}</Card>}
|
||||||
|
|||||||
+46
-2
@@ -168,10 +168,33 @@ async function getDisk() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Procesos destacados (nombre limpio del binario) ────────────────────────
|
// ── 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() {
|
async function getProcesses() {
|
||||||
const result = await sh("ps aux --no-headers --sort=-%mem | head -8");
|
const result = await sh("ps aux --no-headers --sort=-%mem | head -8");
|
||||||
if (!result) return [];
|
if (!result) return [];
|
||||||
return result.split("\n").map(line => {
|
const filas = result.split("\n").map(line => {
|
||||||
const parts = line.trim().split(/\s+/);
|
const parts = line.trim().split(/\s+/);
|
||||||
// Nombre limpio: basename del ejecutable; si es un intérprete (node,
|
// Nombre limpio: basename del ejecutable; si es un intérprete (node,
|
||||||
// python...), añade el basename del script que ejecuta.
|
// python...), añade el basename del script que ejecuta.
|
||||||
@@ -190,12 +213,33 @@ async function getProcesses() {
|
|||||||
}
|
}
|
||||||
command = command.slice(0, 24);
|
command = command.slice(0, 24);
|
||||||
return {
|
return {
|
||||||
|
pid: parts[1],
|
||||||
user: parts[0],
|
user: parts[0],
|
||||||
cpu: parseFloat(parts[2]),
|
cpuMedia: parseFloat(parts[2]), // media desde el arranque (lo que da ps)
|
||||||
mem: parseFloat(parts[3]),
|
mem: parseFloat(parts[3]),
|
||||||
command,
|
command,
|
||||||
};
|
};
|
||||||
}).filter(p => p.mem > 0.5);
|
}).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 ───────────────────────────────────────────────────────────
|
// ── Load / Uptime ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// 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`);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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`);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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");
|
||||||
|
})();
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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,8 +4,15 @@ After=network.target bitcoind.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=armg
|
|
||||||
ExecStart=/usr/bin/node /home/armg/txoko/system-metrics.js
|
# ── 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
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
|
|||||||
Reference in New Issue
Block a user