From 728ba655ee94a564c2592569c78dd551edef88d0 Mon Sep 17 00:00:00 2001 From: Aitor Date: Mon, 27 Jul 2026 14:44:26 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20auditor=C3=ADa=20de=20PSBT=20=E2=80=94?= =?UTF-8?q?=20revisar=20una=20transacci=C3=B3n=20antes=20de=20firmarla?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primera pieza de la app que llega a tiempo: todo lo demás es diagnóstico de lo que ya pasó. Sustituye al validador anterior, que solo comprobaba que el archivo empezara por 'psbt'. Parser BIP174 completo escrito desde cero, validado contra los cinco vectores inválidos del estándar (los rechaza los cinco con mensajes en castellano) y contra PSBTs reales de Sparrow. Todo offline: la PSBT ya trae los importes y scripts de sus entradas, así que no hace falta el nodo — funciona con él sincronizando y sin que se entere. El aviso más valioso es el de los xpubs incrustados: las PSBT llevan dentro las claves maestras de la cartera y están hechas para compartirse, así que quien reciba el archivo puede ver todas las direcciones, el saldo y el historial. No he encontrado ningún wallet que avise de esto. --- CHANGELOG.md | 30 +++++ dashboard.html | 314 +++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 321 insertions(+), 23 deletions(-) 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. +