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:
+56
-6
@@ -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"]]) {
|
||||
|
||||
Reference in New Issue
Block a user