fix: endurecer la validación del xpub tras revisar la criptografía
La matemática pasa todos los vectores oficiales (RIPEMD-160, secp256k1, BIP32
vectores 1 y 2, bech32/BIP173) y no se ha tocado. Los fallos estaban en la
validación de la entrada:
- No se comprobaba la checksum del xpub: un carácter mal copiado generaba 200
direcciones ajenas y el usuario veía su cartera 'sin actividad'. Mismo
patrón de falso negativo silencioso que el resto de fallos de hoy.
- No se validaba la longitud (78 bytes) ni el formato de la clave pública.
- tpub se trataba como mainnet: la red se detectaba por prefijo de texto
('tb'/'u'/'v') y un tpub empieza por 't' pero no por 'tb'. Ahora se detecta
por bytes de versión, con las diez variantes.
- deriveChildPubkey aceptaba índices endurecidos, imposibles desde una clave
pública. No alcanzable desde la UI, pero debe defenderse sola.
Se añade tests/ con las cuatro baterías, documentando también qué NO cubren:
no sustituyen una auditoría externa.
This commit is contained in:
@@ -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");
|
||||
})();
|
||||
Reference in New Issue
Block a user