From 3816b0426ea158f44d356ab6847eecc8c55e41e2 Mon Sep 17 00:00:00 2001 From: Aitor Date: Thu, 4 Jun 2026 19:38:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20watch-only=20por=20xpub=20=E2=80=94=20d?= =?UTF-8?q?erivaci=C3=B3n=20BIP32=20local,=20identifica=20outputs=20propio?= =?UTF-8?q?s=20(recepci=C3=B3n/cambio),=20selector=2020/50/100=20direccion?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard.html | 417 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 406 insertions(+), 11 deletions(-) diff --git a/dashboard.html b/dashboard.html index cbbf7cb..becac09 100644 --- a/dashboard.html +++ b/dashboard.html @@ -1018,7 +1018,274 @@ return results; } - function analyzeTx(tx) { + + // ── BIP32 / bech32 — derivación watch-only ──────────────────────────────── + // Implementación mínima usando Web Crypto API (nativa del navegador). + // No hay dependencias externas. Todo ocurre en memoria, nada sale del nodo. + + // Utilidades de bytes + const B32 = { + // Decodifica base58check → Uint8Array (sin la checksum de 4 bytes) + decodeBase58: (str) => { + const ALPHA = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + let n = 0n; + for (const c of str) { + const idx = ALPHA.indexOf(c); + if (idx < 0) throw new Error("base58 inválido: " + c); + n = n * 58n + BigInt(idx); + } + // Convertir el entero a bytes + let hex = n.toString(16); + if (hex.length % 2) hex = "0" + hex; + const body = hex.length ? hex.match(/.{2}/g).map(b => parseInt(b, 16)) : []; + // Cada '1' inicial en base58 representa un byte 0x00 + let leadingZeros = 0; + 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); + }, + + // SHA256 doble (para checksum base58) + sha256d: async (data) => { + const h1 = await crypto.subtle.digest("SHA-256", data); + return new Uint8Array(await crypto.subtle.digest("SHA-256", h1)); + }, + + // HMAC-SHA512 + hmac512: async (key, data) => { + const k = await crypto.subtle.importKey("raw", key, {name:"HMAC",hash:"SHA-512"}, false, ["sign"]); + return new Uint8Array(await crypto.subtle.sign("HMAC", k, data)); + }, + + // Serializar entero 32 bits big-endian + u32be: (n) => { + const b = new Uint8Array(4); + b[0]=(n>>>24)&0xff; b[1]=(n>>>16)&0xff; b[2]=(n>>>8)&0xff; b[3]=n&0xff; + return b; + }, + + // Concatenar Uint8Arrays + concat: (...arrays) => { + const len = arrays.reduce((s,a) => s + a.length, 0); + const out = new Uint8Array(len); + let off = 0; + for (const a of arrays) { out.set(a, off); off += a.length; } + return out; + }, + }; + + // Aritmética de curva secp256k1 (solo lo necesario: punto + escalar) + const SECP = (() => { + const P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2Fn; + const N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n; + const Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798n; + const Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8n; + + const mod = (a, m=P) => { const r = a % m; return r < 0n ? r + m : r; }; + // Inverso modular por el pequeño teorema de Fermat (robusto con BigInt) + const modPow = (b, e, m) => { + let r = 1n; b = mod(b, m); + while (e > 0n) { if (e & 1n) r = mod(r * b, m); e >>= 1n; b = mod(b * b, m); } + return r; + }; + const inv = (a, m=P) => modPow(mod(a, m), m - 2n, m); + + const addPoints = (P1, P2) => { + if (!P1) return P2; if (!P2) return P1; + const [x1,y1] = P1, [x2,y2] = P2; + if (x1 === x2 && mod(y1 + y2) === 0n) return null; // P + (-P) = infinito + let lam; + if (x1 === x2 && y1 === y2) { + lam = mod(3n * x1 * x1 * inv(2n * y1)); // duplicación + } else { + lam = mod((y2 - y1) * inv(mod(x2 - x1))); // suma de distintos + } + const x3 = mod(lam * lam - x1 - x2); + return [x3, mod(lam * (x1 - x3) - y1)]; + }; + + const mulPoint = (k, pt) => { + let result = null, addend = pt; + while (k > 0n) { + if (k & 1n) result = addPoints(result, addend); + addend = addPoints(addend, addend); + k >>= 1n; + } + return result; + }; + + // Comprimir punto (33 bytes) + const compress = ([x, y]) => { + const xBytes = new Uint8Array(32); + let xn = x; + for (let i = 31; i >= 0; i--) { xBytes[i] = Number(xn & 0xffn); xn >>= 8n; } + const prefix = new Uint8Array([y & 1n ? 3 : 2]); + return B32.concat(prefix, xBytes); + }; + + // Descomprimir punto desde clave pública comprimida (33 bytes) + const decompress = (bytes) => { + const prefix = bytes[0]; + let x = 0n; + for (let i = 1; i < 33; i++) x = (x << 8n) | BigInt(bytes[i]); + const y2 = mod(x * x * x + 7n); + let y = modPow(y2, (P + 1n) / 4n, P); + if ((y & 1n) !== BigInt(prefix & 1)) y = P - y; + return [x, y]; + }; + + return { P, N, G: [Gx, Gy], addPoints, mulPoint, compress, decompress }; + })(); + + // 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) + const data = B32.concat(parentPub, indexBytes); + const I = await B32.hmac512(parentChain, data); + const IL = I.slice(0, 32); + const IR = I.slice(32); + + // childKey = point(IL) + parentPub + let il = 0n; + for (const b of IL) il = (il << 8n) | BigInt(b); + if (il >= SECP.N) throw new Error("derivación inválida"); + + const ilPoint = SECP.mulPoint(il, SECP.G); + const parentPoint = SECP.decompress(parentPub); + const childPoint = SECP.addPoints(ilPoint, parentPoint); + if (!childPoint) throw new Error("punto infinito"); + + return { pub: SECP.compress(childPoint), chain: IR }; + }; + + // Hash160 = RIPEMD160(SHA256(data)) + // Implementamos RIPEMD160 mínimo ya que WebCrypto no lo incluye + const ripemd160 = (data) => { + // Implementación RIPEMD-160 completa + const KL = [0x00000000,0x5A827999,0x6ED9EBA1,0x8F1BBCDC,0xA953FD4E]; + const KR = [0x50A28BE6,0x5C4DD124,0x6D703EF3,0x7A6D76E9,0x00000000]; + const RL = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,7,4,13,1,10,6,15,3,12,0,9,5,2,14,11,8,3,10,14,4,9,15,8,1,2,7,0,6,13,11,5,12,1,9,11,10,0,8,12,4,13,3,7,15,14,5,6,2,4,0,5,9,7,12,2,10,14,1,3,8,11,6,15,13]; + const RR = [5,14,7,0,9,2,11,4,13,6,15,8,1,10,3,12,6,11,3,7,0,13,5,10,14,15,8,12,4,9,1,2,15,5,1,3,7,14,6,9,11,8,12,2,10,0,4,13,8,6,4,1,3,11,15,0,5,12,2,13,9,7,10,14,12,15,10,4,1,5,8,7,6,2,13,14,0,3,9,11]; + const SL = [11,14,15,12,5,8,7,9,11,13,14,15,6,7,9,8,7,6,8,13,11,9,7,15,7,12,15,9,11,7,13,12,11,13,6,7,14,9,13,15,14,8,13,6,5,12,7,5,11,12,14,15,14,15,9,8,9,14,5,6,8,6,5,12,9,15,5,11,6,8,13,12,5,12,13,14,11,8,5,6]; + const SR = [8,9,9,11,13,15,15,5,7,7,8,11,14,14,12,6,9,13,15,7,12,8,9,11,7,7,12,7,6,15,13,11,9,7,15,11,8,6,6,14,12,13,5,14,13,13,7,5,15,5,8,11,14,14,6,14,6,9,12,9,12,5,15,8,8,5,12,9,12,5,14,6,8,13,6,5,15,13,11,11]; + + const f = (j,x,y,z) => j<16?(x^y^z):j<32?((x&y)|(~x&z)):j<48?((x|~y)^z):j<64?((x&z)|(y&~z)):(x^(y|~z)); + const rol = (x,n) => ((x<>>(32-n)))>>>0; + + // Padding + const msgLen = data.length; + const padLen = ((msgLen + 8) % 64 <= 55) ? 55 - (msgLen % 64) : 119 - (msgLen % 64); + const padded = new Uint8Array(msgLen + padLen + 9); + padded.set(data); + padded[msgLen] = 0x80; + const bitLen = msgLen * 8; + padded[padded.length-8] = bitLen & 0xff; + padded[padded.length-7] = (bitLen>>>8) & 0xff; + padded[padded.length-6] = (bitLen>>>16) & 0xff; + padded[padded.length-5] = (bitLen>>>24) & 0xff; + + let [h0,h1,h2,h3,h4] = [0x67452301,0xEFCDAB89,0x98BADCFE,0x10325476,0xC3D2E1F0]; + + for (let i = 0; i < padded.length; i += 64) { + const X = new Int32Array(16); + for (let j = 0; j < 16; j++) { + X[j] = padded[i+j*4] | (padded[i+j*4+1]<<8) | (padded[i+j*4+2]<<16) | (padded[i+j*4+3]<<24); + } + let [al,bl,cl,dl,el] = [h0,h1,h2,h3,h4]; + let [ar,br,cr,dr,er] = [h0,h1,h2,h3,h4]; + for (let j = 0; j < 80; j++) { + const jj = Math.floor(j/16); + let T = rol((al + f(j,bl,cl,dl) + X[RL[j]] + KL[jj])>>>0, SL[j]); + T = (T + el)>>>0; al=el; el=dl; dl=rol(cl,10); cl=bl; bl=T; + T = rol((ar + f(79-j,br,cr,dr) + X[RR[j]] + KR[jj])>>>0, SR[j]); + T = (T + er)>>>0; ar=er; er=dr; dr=rol(cr,10); cr=br; br=T; + } + const T = (h1 + cl + dr)>>>0; + h1 = (h2 + dl + er)>>>0; h2 = (h3 + el + ar)>>>0; + h3 = (h4 + al + br)>>>0; h4 = (h0 + bl + cr)>>>0; h0 = T; + } + const out = new Uint8Array(20); + const view = new DataView(out.buffer); + [h0,h1,h2,h3,h4].forEach((h,i) => view.setUint32(i*4, h, true)); + return out; + }; + + const hash160 = async (pubkey) => { + const sha = new Uint8Array(await crypto.subtle.digest("SHA-256", pubkey)); + return ripemd160(sha); + }; + + // Bech32 — generar dirección bc1q desde hash160 + const toBech32 = (hrp, data) => { + const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + const polymod = (values) => { + const GEN = [0x3b6a57b2,0x26508e6d,0x1ea119fa,0x3d4233dd,0x2a1462b3]; + let chk = 1; + for (const v of values) { + const b = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ v; + for (let i = 0; i < 5; i++) if ((b >> i) & 1) chk ^= GEN[i]; + } + return chk; + }; + const hrpExpand = (hrp) => { + const ret = []; + for (const c of hrp) ret.push(c.charCodeAt(0) >> 5); + ret.push(0); + for (const c of hrp) ret.push(c.charCodeAt(0) & 31); + return ret; + }; + const createChecksum = (hrp, data) => { + const values = [...hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0]; + const mod = polymod(values) ^ 1; + return Array.from({length:6}, (_,i) => (mod >> (5*(5-i))) & 31); + }; + const convertbits = (data, frombits, tobits, pad=true) => { + let acc = 0, bits = 0; + const ret = []; + const maxv = (1 << tobits) - 1; + for (const v of data) { + acc = (acc << frombits) | v; + bits += frombits; + while (bits >= tobits) { bits -= tobits; ret.push((acc >> bits) & maxv); } + } + if (pad && bits > 0) ret.push((acc << (tobits - bits)) & maxv); + return ret; + }; + const words = [0, ...convertbits(data, 8, 5)]; // version 0 + const checksum = createChecksum(hrp, words); + return hrp + "1" + [...words, ...checksum].map(d => CHARSET[d]).join(""); + }; + + // Derivar N direcciones desde xpub (rama 0=recepción, 1=cambio) + const deriveAddresses = async (xpubStr, count=20) => { + // Decodificar xpub/zpub + const raw = B32.decodeBase58(xpubStr.trim()); + // raw[0..3]=version, [4]=depth, [5..8]=fingerprint, [9..12]=childIndex + // [13..44]=chainCode, [45..77]=pubKey + const chainCode = raw.slice(13, 45); + const pubKey = raw.slice(45, 78); + // 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 result = { receive: [], change: [], fingerprint }; + for (const [branch, label] of [[0,"receive"],[1,"change"]]) { + // Derivar la clave de rama (m/branch) + const branchKey = await deriveChildPubkey(pubKey, chainCode, branch); + for (let i = 0; i < count; i++) { + const childKey = await deriveChildPubkey(branchKey.pub, branchKey.chain, i); + const h160 = await hash160(childKey.pub); + result[label].push(toBech32(hrp, Array.from(h160))); + } + } + return result; + }; + + function analyzeTx(tx) { const checks = []; const weights = { input_reuse: 25, input_type_mixing: 14, output_type_mismatch: 8, @@ -1606,6 +1873,13 @@ const checks = []; let score = 100; const txCount = addr.chain_stats ? addr.chain_stats.tx_count : 0; const utxoCount = addr.utxos ? addr.utxos.length : 0; + + // Dirección sin usar: no tiene privacidad buena ni mala, simplemente está virgen + if (txCount === 0) { + checks.push({ id:"unused", label:"Dirección sin usar", certainty:"CERTEZA", pass:true, detail:"Esta dirección no tiene historial on-chain. No ha recibido ni enviado fondos.", penalty:0, informational:true }); + return { score:null, checks, band:"NUEVA", summary:"Dirección sin usar", summaryColor:C.t2, wallets:[], unused:true }; + } + const reused = txCount > 1; const penalty = txCount > 10 ? 40 : txCount > 3 ? 25 : 15; checks.push({ id:"addr_reuse", label:"Reutilización de dirección", certainty:"CERTEZA", pass:!reused, detail: reused ? `Usada en ${txCount} transacciones. Permite vincular pagos y construir un perfil.` : "Usada una sola vez. Correcto.", penalty }); @@ -1689,6 +1963,9 @@ function generateReport(analysis, tx) { const {score, checks} = analysis; + // Detectar si el objeto analizado es una dirección en vez de una transacción + const isAddr = !!(tx && (tx.address || tx.chain_stats)); + const noun = isAddr ? "dirección" : "transacción"; const failed = checks.filter(c => !c.pass && !c.informational); const critical = failed.filter(c => c.certainty === "CERTEZA"); const probable = failed.filter(c => c.certainty === "PROBABLE"); @@ -1709,6 +1986,14 @@ const sections = []; + // Caso especial: dirección sin usar + if (analysis.unused) { + sections.push({ title:"Diagnóstico", text:"Esta dirección no tiene historial on-chain — no ha recibido ni enviado fondos. No hay nada que analizar todavía." }); + sections.push({ title:"Qué puede saber un analista", text:"Nada. Una dirección sin usar no revela información hasta que recibe su primer pago." }); + sections.push({ title:"Recomendaciones", text:"Cuando recibas fondos en esta dirección, úsala una sola vez para mantener una buena privacidad." }); + return sections; + } + // ── 1. DIAGNÓSTICO PRINCIPAL ───────────────────────────────────── let diag = ""; if (hasInputReuse && hasChange) { @@ -1726,7 +2011,7 @@ } else if (hasDust) { diag = "Se detectan outputs de dust que podrían ser parte de un ataque de dust linking. Si gastas esos outputs combinándolos con otros UTXOs, revelarás qué UTXOs pertenecen al mismo wallet."; } else if (failed.length === 0) { - diag = "No se detectan problemas graves de privacidad en esta transacción con los datos disponibles."; + diag = `No se detectan problemas graves de privacidad en esta ${noun} con los datos disponibles.`; } else { diag = `Se detectan ${failed.length} característica${failed.length>1?"s":""} que reducen la privacidad, ninguna de gravedad crítica por sí sola.`; } @@ -1776,13 +2061,13 @@ if (topRecs.length > 0) { sections.push({ title:"Recomendaciones", recs: topRecs }); } else { - sections.push({ title:"Recomendaciones", text:"No hay acciones correctivas prioritarias para esta transacción." }); + sections.push({ title:"Recomendaciones", text:`No hay acciones correctivas prioritarias para esta ${noun}.` }); } return sections; } - function PrivacyLab({analysis, tx, labels}) { + function PrivacyLab({analysis, tx, labels, myAddr}) { const [expanded,setExpanded] = useState(null); const [showDidactic,setShowDidactic] = useState(null); const [showReport,setShowReport] = useState(false); @@ -1889,7 +2174,9 @@
NIVEL DE PRIVACIDAD
{summary}
- {tx + {analysis.unused + ? "Esta dirección no se ha usado todavía. No tiene historial on-chain que analizar." + : tx ? (score>=75?"La transacción presenta buenas propiedades de privacidad.":score>=45?"La transacción es funcional, pero tiene características que reducen su privacidad.":"Esta transacción tiene características que facilitan su rastreo.") : (score>=75?"La dirección presenta buenas propiedades de privacidad.":score>=45?"La dirección es funcional, pero tiene características que reducen su privacidad.":"Esta dirección tiene características que facilitan su rastreo.")}
@@ -3440,6 +3727,39 @@ const [labels,setLabels]=useState(new Map()); // BIP-329: ref -> label const [showLabelModal,setShowLabelModal]=useState(false); const [labelInfo,setLabelInfo]=useState(null); // {count, file} + const [walletAddrs,setWalletAddrs]=useState(null); // {receive:[], change:[]} + const [xpubInput,setXpubInput]=useState(""); + const [xpubCount,setXpubCount]=useState(20); + const [xpubLoading,setXpubLoading]=useState(false); + const [xpubError,setXpubError]=useState(null); + const [showWalletPanel,setShowWalletPanel]=useState(false); + + const loadWallet = async () => { + const xpub = xpubInput.trim(); + if (!xpub) return; + if (!/^[xyzuvt]pub[1-9A-HJ-NP-Za-km-z]{100,}$/.test(xpub)) { + setXpubError("Introduce un xpub/zpub válido"); return; + } + setXpubLoading(true); setXpubError(null); + try { + const addrs = await deriveAddresses(xpub, xpubCount); + setWalletAddrs(addrs); + setShowWalletPanel(false); + } catch(e) { + setXpubError("Error al derivar direcciones: " + e.message); + } + setXpubLoading(false); + }; + + const clearWallet = () => { setWalletAddrs(null); setXpubInput(""); setXpubError(null); }; + + // Determinar si una dirección es mía y de qué rama + const myAddr = (addr) => { + if (!walletAddrs) return null; + if (walletAddrs.receive.includes(addr)) return "recepción"; + if (walletAddrs.change.includes(addr)) return "cambio"; + return null; + }; const loadLabels = (file) => { const reader = new FileReader(); @@ -3482,14 +3802,69 @@ return (
{!base&&} -
+
AUDITORÍA - +
+ + +
+ {showWalletPanel&&( + +
+
WALLET WATCH-ONLY (xpub)
+ +
+
+ Pega tu xpub/zpub desde Sparrow: Settings → Keystore → Master Public Key. Txoko deriva tus direcciones en local — el xpub no sale del navegador. +
+ {walletAddrs?( +
+
✓ {walletAddrs.receive.length} direcciones de recepción + {walletAddrs.change.length} de cambio derivadas
+
Las direcciones de tus txs analizadas se marcan automáticamente como tuyas. Compara estas con Sparrow para verificar:
+
+ {walletAddrs.receive.slice(0,3).map((a,i)=>( +
+ recep #{i}: {a} +
+ ))} +
+ +
+ ):( +
+ setXpubInput(e.target.value)} + onKeyDown={e=>e.key==="Enter"&&loadWallet()} + placeholder="xpub... o zpub..." + style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"8px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.7rem",outline:"none"}} + /> + + +
+ )} + {!walletAddrs&&
Direcciones a derivar por rama (recepción y cambio). Más direcciones = más cobertura pero más tiempo de cálculo.
} + {xpubError&&
{xpubError}
} +
+ )} + {showLabelModal&&(
@@ -3567,8 +3942,28 @@
+ {walletAddrs&&tx.vout&&tx.vout.some(o=>myAddr(o.scriptpubkey_address))&&( +
+
OUTPUTS DE TU WALLET
+
+ {tx.vout.map((o,i)=>{ + const mine = myAddr(o.scriptpubkey_address); + if (!mine) return null; + const lbl = labels?.get(o.scriptpubkey_address); + return ( +
+ {mine==="cambio"?"↺ cambio":"↓ recepción"} + {fmt.sats(o.value)} + {lbl&&· {lbl}} + {o.scriptpubkey_address?.slice(0,12)}… +
+ ); + })} +
+
+ )}
- +