diff --git a/CHANGELOG.md b/CHANGELOG.md index e8ca64b..21eb303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,36 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/): - **MENOR** — características nuevas que no rompen lo anterior - **PARCHE** — arreglos de errores +--- +## [1.12.0] — 2026-07-27 +### Añadido +- **Auditoría de PSBT: revisar una transacción ANTES de firmarla.** Hasta ahora + toda la app era diagnóstico *a posteriori* — te contaba con detalle lo que ya + había pasado y no podías cambiar. Esta es la primera pieza que llega a tiempo. + Sustituye al antiguo "validador", que solo comprobaba que el archivo empezara + por `psbt` y decía su tamaño. + - **Parser completo de BIP174 escrito desde cero**, sin librerías, como el + resto del proyecto. Validado contra los cinco vectores inválidos que publica + el propio estándar: los rechaza los cinco, cada uno con un mensaje que + explica en castellano qué está mal (truncada, sin salidas, ya firmada, + sin transacción interna, con claves repetidas). + - **Todo el análisis es offline.** Una PSBT bien formada ya lleva dentro los + importes y scripts de sus entradas, así que no hace falta consultar el nodo: + funciona con el nodo sincronizando, y el nodo ni se entera. + - Acepta base64, hexadecimal o abrir el archivo `.psbt` directamente. + - **Aviso crítico sobre xpubs incrustados.** Las PSBT suelen llevar dentro las + claves públicas maestras de la cartera, y están hechas para compartirse — se + mandan por correo o chat para que el resto firme. Quien reciba el archivo + puede derivar todas las direcciones, presentes y futuras, y ver el saldo y + el historial completos. No puede gastar, pero lo ve todo. Ningún wallet + avisa de esto. + - Detecta además: cambio identificable por tipo de dirección, importes + redondos que delatan cuál es el pago, envíos a la propia cartera, carteras + multifirma (con su M-de-N) y PSBTs incompletas sin los importes de entrada. + - Cada aviso mantiene el formato del resto de la app — Hecho, Consecuencia y + **Qué puedes hacer** —, que aquí cobra sentido literal: todavía estás a + tiempo de cambiar la transacción. + --- ## [1.11.0] — 2026-07-27 ### Añadido diff --git a/dashboard.html b/dashboard.html index f45fa37..1152ad2 100644 --- a/dashboard.html +++ b/dashboard.html @@ -6038,51 +6038,319 @@ ); } - // ── Decodificador PSBT ───────────────────────────────────────────────── + // ── Parser PSBT (BIP174) ─────────────────────────────────────────────── + // Escrito desde cero, sin librerías, como el resto del proyecto. Validado + // contra los cinco vectores inválidos del propio BIP174 (los rechaza los + // cinco) y contra PSBTs reales de Sparrow. + // + // Todo el análisis es OFFLINE: una PSBT bien formada ya lleva dentro los + // importes y scripts de sus entradas, así que no hace falta consultar el + // nodo. Eso significa que puedes auditar una transacción antes de firmarla + // aunque el nodo esté sincronizando — y que el nodo no se entera. + function parsePsbt(bytes) { + let p = 0; + const need = (n) => { if (p + n > bytes.length) throw new Error("PSBT truncada: el archivo se acaba antes de tiempo."); }; + const u8 = () => { need(1); return bytes[p++]; }; + const u32 = () => { need(4); const v = bytes[p]|(bytes[p+1]<<8)|(bytes[p+2]<<16)|(bytes[p+3]<<24); p+=4; return v>>>0; }; + const u64 = () => { need(8); let v=0n; for(let i=7;i>=0;i--) v=(v<<8n)|BigInt(bytes[p+i]); p+=8; return Number(v); }; + const take = (n) => { need(n); return bytes.slice(p, p+=n); }; + const varint = () => { const v=u8(); if(v<0xfd) return v; if(v===0xfd){need(2);const r=bytes[p]|(bytes[p+1]<<8);p+=2;return r;} if(v===0xfe) return u32(); return u64(); }; + + if (bytes.length < 5 || bytes[0]!==0x70||bytes[1]!==0x73||bytes[2]!==0x62||bytes[3]!==0x74||bytes[4]!==0xff) + throw new Error("Esto no es una PSBT: le falta la cabecera que las identifica. ¿Has pegado una transacción normal en vez de una a medio firmar?"); + p = 5; + + // Cada sección es una lista de pares clave/valor que termina en un 0x00. + // El estándar PROHÍBE claves repetidas: si aparecen, el archivo está + // corrupto o manipulado, y conviene no seguir leyéndolo. + const readMap = (donde) => { + const out = []; const vistas = new Set(); + for (;;) { + if (p >= bytes.length) throw new Error(`PSBT truncada: la sección de ${donde} no termina.`); + const klen = varint(); + if (klen === 0) return out; + const key = take(klen); + const kh = Array.from(key).map(b=>b.toString(16).padStart(2,"0")).join(""); + if (vistas.has(kh)) throw new Error(`PSBT inválida: hay una clave repetida en ${donde}. El estándar no lo permite.`); + vistas.add(kh); + const vlen = varint(); + out.push({ type: key[0], key: key.slice(1), value: take(vlen) }); + } + }; + + const global = readMap("la cabecera"); + const utxRec = global.find(e => e.type === 0x00); + if (!utxRec) throw new Error("PSBT inválida: no contiene la transacción sin firmar."); + + const tx = utxRec.value; let q = 0; + const tneed=(n)=>{ if(q+n>tx.length) throw new Error("La transacción que hay dentro está truncada."); }; + const tu32=()=>{ tneed(4); const v=tx[q]|(tx[q+1]<<8)|(tx[q+2]<<16)|(tx[q+3]<<24); q+=4; return v>>>0; }; + const tu64=()=>{ tneed(8); let v=0n; for(let i=7;i>=0;i--) v=(v<<8n)|BigInt(tx[q+i]); q+=8; return Number(v); }; + const tvar=()=>{ const v=tx[q++]; if(v<0xfd) return v; if(v===0xfd){const r=tx[q]|(tx[q+1]<<8);q+=2;return r;} if(v===0xfe) return tu32(); return tu64(); }; + const ttake=(n)=>{ tneed(n); return tx.slice(q, q+=n); }; + const hx=(a)=>Array.from(a).map(b=>b.toString(16).padStart(2,"0")).join(""); + + const version = tu32(); + const nIn = tvar(); + if (nIn === 0) throw new Error("PSBT inválida: la transacción no tiene ninguna entrada."); + const vin = []; + for (let i=0;i 0) throw new Error("PSBT inválida: la transacción ya lleva firmas incrustadas donde no debería."); + vin.push({ txid, vout, sequence: tu32() }); + } + const nOut = tvar(); + if (nOut === 0) throw new Error("PSBT inválida: la transacción no tiene ninguna salida."); + const vout = []; + for (let i=0;i Array.from(a).map(b=>b.toString(16).padStart(2,"0")).join(""); + + const xpubs = psbt.global.filter(e => e.type === 0x01); + if (xpubs.length > 0) { + avisos.push({ + nivel: "critico", id: "xpub_expuesto", + titulo: xpubs.length === 1 ? "El archivo lleva dentro una clave pública maestra" : `El archivo lleva dentro ${xpubs.length} claves públicas maestras`, + hecho: `Hecho (certeza): además de lo necesario para firmar, la PSBT incluye ${xpubs.length} xpub de nivel cuenta.`, + consecuencia: "Quien reciba este archivo puede derivar TODAS las direcciones de la cartera, las que ya has usado y las que usarás, y ver su saldo e historial completos. No puede gastar nada, pero lo ve todo. Y las PSBT están hechas para compartirse: acaban en un correo o un chat para que el resto firme.", + quePuedesHacer: "Compártela solo con los cosignatarios y por un canal cifrado. Si los demás firmantes ya tienen cargada la configuración de la cartera, no necesitan estos xpubs: Sparrow permite excluirlos al exportar.", + }); + } + + const inVals = psbt.inputs.map(m => { + const w = m.find(e => e.type === 0x01); + if (w) { let v=0n; for(let i=7;i>=0;i--) v=(v<<8n)|BigInt(w.value[i]); return Number(v); } + return null; + }); + const conocidos = inVals.every(v => v !== null); + const totalIn = conocidos ? inVals.reduce((a,b)=>a+b,0) : null; + const totalOut = psbt.vout.reduce((a,o)=>a+o.value,0); + const fee = totalIn !== null ? totalIn - totalOut : null; + + const salidasPropias = psbt.outputs.map(m => m.some(e=>e.type===0x02)); + const todasPropias = salidasPropias.length > 0 && salidasPropias.every(Boolean); + + if (todasPropias && psbt.vout.length === 1) { + avisos.push({ + nivel: "info", id: "autoenvio", + titulo: "Todo el importe vuelve a tu propia cartera", + hecho: "Hecho (certeza): la única salida lleva rutas de derivación de tu cartera, así que no estás pagando a nadie.", + consecuencia: "Es un movimiento interno: consolidar monedas, acelerar una transacción atascada o cambiar de dirección. No revela un pago, pero deja un patrón reconocible en la cadena — una entrada, una salida, sin cambio.", + quePuedesHacer: null, + }); + } + + const tiposIn = new Set(psbt.inputs.map(m => { + const w = m.find(e=>e.type===0x01); + if (!w) return null; + const l = w.value[8]; + return psbtScriptType(w.value.slice(9, 9+l)); + }).filter(Boolean)); + const tiposOut = psbt.vout.map(o => psbtScriptType(o.script)); + + if (psbt.vout.length === 2 && tiposIn.size === 1 && tiposOut[0] !== tiposOut[1]) { + const t = [...tiposIn][0]; + if (tiposOut.filter(x => x === t).length === 1) { + avisos.push({ + nivel: "aviso", id: "cambio_por_tipo", + titulo: "Tu cambio se distingue por el tipo de dirección", + hecho: `Hecho (certeza): gastas monedas de tipo ${PSBT_TIPOS[t]} y solo una de las dos salidas usa ese mismo tipo.`, + consecuencia: "Cualquiera que mire la transacción deduce cuál de las dos salidas es tu cambio — y por ahí puede seguir tus gastos posteriores.", + quePuedesHacer: "Cuando puedas elegir, gasta monedas del mismo tipo que la dirección a la que pagas. No siempre depende de ti: manda el formato que use quien cobra.", + }); + } + } + + if (psbt.vout.length === 2) { + const redondos = psbt.vout.map((o,i)=>({i, v:o.value})).filter(o => o.v % 100000 === 0); + if (redondos.length === 1) { + avisos.push({ + nivel: "aviso", id: "valor_redondo", + titulo: "Una de las salidas tiene un importe redondo", + hecho: `Hecho (certeza): la salida ${redondos[0].i+1} vale exactamente ${(redondos[0].v/1e8).toFixed(8)} BTC.`, + consecuencia: "Las personas pagan cifras redondas y el cambio es lo que sobra, con todos sus decimales. Refuerza la deducción de cuál salida es el pago y cuál el cambio.", + quePuedesHacer: "Si el importe lo decides tú, añadir unos satoshis sueltos rompe la señal sin coste real.", + }); + } + } + + const conWitnessScript = psbt.inputs.find(m => m.some(e=>e.type===0x05)); + if (conWitnessScript) { + const ws = conWitnessScript.find(e=>e.type===0x05).value; + const m = ws[0]>=0x51&&ws[0]<=0x60 ? ws[0]-0x50 : null; + const n = ws[ws.length-2]>=0x51&&ws[ws.length-2]<=0x60 ? ws[ws.length-2]-0x50 : null; + avisos.push({ + nivel: "info", id: "multisig", + titulo: (m&&n) ? `Cartera multifirma de ${m} de ${n}` : "Cartera multifirma", + hecho: `Hecho (certeza): las entradas se gastan con un script de firma múltiple${(m&&n)?`, de tipo ${m} de ${n}`:""}.`, + consecuencia: "Al gastar, ese script queda escrito en la cadena para siempre. El multisig es minoritario, así que te coloca en un grupo pequeño y reconocible: alguien que guarda cantidades con la custodia repartida.", + quePuedesHacer: "Es el precio de esta forma de custodia y con este esquema no se puede evitar. Taproot permite gastos multifirma que por fuera parecen pagos corrientes, pero implica cambiar de configuración.", + }); + } + + if (!conocidos) { + avisos.push({ + nivel: "aviso", id: "sin_importes", + titulo: "No se puede calcular la comisión", + hecho: "Hecho (certeza): alguna entrada no incluye el importe de la moneda que gasta.", + consecuencia: "Sin ese dato no se sabe cuánto se paga de comisión. Una PSBT completa debería traerlo; que falte suele indicar que la generó una herramienta incompleta.", + quePuedesHacer: "Ábrela en el wallet que la creó y vuelve a exportarla.", + }); + } + + return { + resumen: { + entradas: psbt.vin.length, salidas: psbt.vout.length, + totalIn, totalOut, fee, locktime: psbt.locktime, + rbf: psbt.vin.some(i => i.sequence < 0xfffffffe), + tiposEntrada: [...tiposIn].map(t=>PSBT_TIPOS[t]), + tiposSalida: tiposOut.map(t=>PSBT_TIPOS[t]), + xpubs: xpubs.length, multisig: !!conWitnessScript, + firmasPresentes: psbt.inputs.reduce((a,m)=>a+m.filter(e=>e.type===0x02).length,0), + size: psbt.size, + }, + avisos, + }; + } + + // ── Auditoría de PSBT ────────────────────────────────────────────────── function ToolPsbt() { const [input, setInput] = useState(""); const [result, setResult] = useState(null); const [err, setErr] = useState(null); + // Acepta base64 (lo habitual al copiar) o hexadecimal. const decode = () => { - const raw = input.trim(); + const raw = input.trim().replace(/\s/g,""); if (!raw) return; setErr(null); setResult(null); try { - // Basic PSBT validation — starts with magic bytes "psbt" in base64 - const bytes = atob(raw.replace(/\s/g,"")); - if (!bytes.startsWith("psbt")) throw new Error("No es una PSBT válida — debe empezar por 'psbt'"); - // Extract basic info from magic bytes - setResult({ - valid: true, - size: bytes.length, - note: "PSBT válida. Para un análisis completo necesitas un wallet como Sparrow o Coldcard. Esta herramienta verifica el formato.", - }); + let bytes; + if (/^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0) { + bytes = new Uint8Array(raw.match(/../g).map(h=>parseInt(h,16))); + } else { + const bin = atob(raw); + bytes = new Uint8Array(bin.length); + for (let i=0;i { + if (!file) return; + const r = new FileReader(); + r.onload = (e) => { + const bytes = new Uint8Array(e.target.result); + setErr(null); setResult(null); + try { setResult(analyzePsbt(parsePsbt(bytes))); } + catch(err2) { setErr(err2.message); } + }; + r.readAsArrayBuffer(file); + }; + + const colorNivel = (n) => n==="critico"?C.red : n==="aviso"?C.amber : C.blue; + const etiquetaNivel = (n) => n==="critico"?"CRÍTICO" : n==="aviso"?"AVISO" : "INFORMATIVO"; + const R = result?.resumen; + return ( -
Validador PSBT
-
Pega una PSBT en base64 para verificar que es válida antes de firmarla. Nunca se envía a ningún servidor — todo es local.
+
Auditoría de PSBT
+
+ Revisa una transacción antes de firmarla, cuando todavía puedes cambiarla. Todo se analiza en tu navegador: el archivo no se envía a ningún sitio, ni siquiera a tu nodo. +