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:
Aitor
2026-07-27 14:59:48 +02:00
parent 728ba655ee
commit 8d58ef7ae0
7 changed files with 316 additions and 6 deletions
+56 -6
View File
@@ -1055,8 +1055,27 @@
for (const c of str) { if (c === "1") leadingZeros++; else break; }
const full = new Uint8Array(leadingZeros + body.length);
full.set(body, leadingZeros);
// full = 82 bytes (78 payload + 4 checksum); devolvemos el payload
return full.slice(0, full.length - 4);
return full; // completo: payload + 4 bytes de checksum
},
// 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)
@@ -1153,7 +1172,13 @@
// Derivación BIP32 de clave pública hija (solo clave pública, sin hardened)
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 I = await B32.hmac512(parentChain, data);
const IL = I.slice(0, 32);
@@ -1272,17 +1297,42 @@
};
// 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) => {
// Decodificar xpub/zpub
const raw = B32.decodeBase58(xpubStr.trim());
// decodeBase58Check comprueba la suma de verificación: un carácter mal
// 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
// [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 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
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 };
for (const [branch, label] of [[0,"receive"],[1,"change"]]) {