wandres.dev
FUNCIONES DE HASH · SHA-256 frente a BLAKE3

Elegir en tu sistema: interoperar, medir y poder cambiar

La decisión no es una función sino tres: qué nombres publicas, cuántos bytes ocupa cada identificador y cómo dejas escrita desde el día uno la posibilidad de cambiar de función más adelante.

⏱ 20 min

Las cuatro lecciones anteriores dan las piezas y ninguna da la respuesta, porque la respuesta no es una función. Quien plantea la elección como SHA-256 frente a BLAKE3 está resolviendo un problema que no tiene, y lo revela el hecho de que casi todos los sistemas maduros de este terreno acaban usando las dos, en sitios distintos y por motivos distintos, sin que eso sea una inconsistencia. La decisión real se descompone en tres preguntas que se responden por separado y que ordenadas mal producen arquitecturas difíciles de corregir. Qué identificadores salen de tu sistema y tienen que ser reproducibles por terceros. Cuántos bytes te cuesta cada nombre y por cuántos nombres se multiplica esa cifra. Y qué has dejado escrito en tu formato para el día, que llegará, en que la función que elegiste hoy deje de ser aceptable. Esta última es la que casi nadie contesta a tiempo, y es también la única cuyo coste crece de forma catastrófica con el retraso.

🎯 Al terminar esta lección sabrás
  • Separar los identificadores que se publican de los que solo circulan dentro del sistema, y elegir función para cada población.
  • Dimensionar la longitud del identificador contando todos los sitios donde se multiplica.
  • Diseñar el formato de nombre con agilidad criptográfica desde el principio y evitar la negociación insegura.
  • Aplicar un procedimiento de decisión reproducible en lugar de elegir por reputación.

Dos poblaciones de identificadores

El error de encuadre que hay que deshacer primero es suponer que un sistema tiene un solo tipo de nombre. Tiene al menos dos y sus requisitos son casi opuestos. Están los identificadores publicados, que cruzan la frontera del sistema: los que aparecen en una firma que otro verifica, en un fichero de bloqueo de dependencias que se comparte, en una copia de seguridad que alguien restaurará dentro de diez años con herramientas que hoy no existen, en un requisito de auditoría. Y están los identificadores internos, que solo viven dentro de tu almacén: nombres de trozos, claves del índice de deduplicación, referencias entre nodos del árbol, sumas de comprobación de integridad periódica.

Para la primera población el criterio dominante es que cualquiera pueda recalcular el valor con lo que ya tiene instalado, sin pedir permiso ni añadir dependencias, hoy y dentro de dos décadas. Ese criterio favorece a SHA-256 con una fuerza que ningún banco de pruebas revierte, y en contextos sujetos a certificación no deja alternativa.

Para la segunda población el criterio dominante es el coste por byte, porque son nombres que se calculan millones de veces, que nadie fuera del sistema verá nunca y cuya reproducibilidad externa no aporta absolutamente nada. Ahí la interoperabilidad no es una ventaja sino un impuesto que se paga sin recibir nada a cambio.

flowchart TB
S[Este identificador cruza la frontera del sistema] -->|si| P[Poblacion publicada]
S -->|no| I[Poblacion interna]
P --> C1[Manda la reproducibilidad por terceros]
P --> C2[Manda el cumplimiento normativo si aplica]
I --> C3[Manda el coste por byte]
I --> C4[Manda la verificacion parcial]
style P fill:#89b4fa,color:#11111b
style I fill:#a6e3a1,color:#11111b

Conviene ser explícito sobre una consecuencia que incomoda a mucha gente: usar dos funciones distintas en el mismo sistema no es una deuda técnica ni una señal de indecisión. Es la respuesta correcta cuando hay dos poblaciones con requisitos distintos, y el error sería el contrario, imponer a una población el criterio de la otra. Lo que sí es deuda técnica es no haber documentado cuál se usa dónde, porque entonces alguien acabará comparando un valor de una población con uno de la otra.

ℹ️
El listado de adopción dice más que cualquier banco de pruebas

Vale la pena mirar dónde se ha adoptado BLAKE3 según su propio repositorio oficial, porque el patrón es revelador: sistemas de construcción, gestores de paquetes, cachés de compilación, un sistema de ficheros con verificación de integridad, y varios proyectos del ecosistema local-first. Todos ellos tienen en común que resumen volúmenes enormes de datos internos y que la reproducibilidad por terceros les importa poco o nada. Es exactamente la población interna del razonamiento anterior, y la coincidencia no es casual: cuando una decisión se toma con criterio, converge.

La longitud del identificador y sus multiplicaciones

Doscientos cincuenta y seis bits son treinta y dos bytes, que en hexadecimal son sesenta y cuatro caracteres. Parece poco hasta que se cuentan los sitios donde se multiplica. Un almacén con diez millones de bloques distintos guarda trescientos veinte megabytes solo en nombres dentro de su índice, sin contar la ubicación asociada ni la estructura de la tabla. Los nodos padre del árbol añaden su parte, que sobre trozos de mil veinticuatro bytes queda por debajo del seis coma veinticinco por ciento del volumen de datos. Y cada manifiesto, cada referencia entre objetos y cada mensaje de sincronización arrastra el mismo tamaño.

De ahí la tentación de truncar, que es una decisión legítima si se toma con los ojos abiertos y desastrosa si se toma por estética. Truncar a ciento veintiocho bits deja sesenta y cuatro bits de resistencia a colisiones por el argumento del cumpleaños. Sesenta y cuatro bits son ampliamente suficientes contra colisiones accidentales, incluso con billones de objetos, y son insuficientes contra un adversario que controle las dos entradas, que es precisamente el escenario de un almacén que deduplica entre usuarios o de una firma sobre una raíz.

// La longitud no se elige mirando un nombre: se elige contando donde aparece
const bloques = 10_000_000;
const bytesNombre = 32;
const enIndice = bloques * bytesNombre;            // 320 MB solo de claves
const enPadres = bloques * 64;                     // dos nombres por nodo padre
// Truncar a 16 bytes ahorra la mitad y deja 64 bits de resistencia a colisiones

La regla práctica que evita el desastre es separar el truncamiento de presentación del truncamiento de almacenamiento. Mostrar los primeros doce caracteres en una interfaz o en un registro es inofensivo porque el valor completo sigue guardado y la comparación de verdad se hace sobre él. Guardar solo esos doce caracteres es otra cosa completamente distinta, es irreversible y compromete la propiedad de la que depende todo lo demás. Que las dos operaciones se escriban casi igual en el código es la razón de que se confundan.

⚠️
Truncar es una decisión de una sola dirección

Se puede pasar de un identificador largo a uno corto en cualquier momento, y no se puede volver atrás sin releer todos los datos. Peor: si los nombres truncados ya están grabados en referencias internas del árbol, en manifiestos publicados y en copias de seguridad, deshacerlo exige reescribir el grafo entero. Antes de truncar hay que contestar dos preguntas por escrito: si existe algún camino por el que un tercero pueda influir en el contenido que se nombra, y si algún día habrá que subir de longitud. Si la primera respuesta es sí o la segunda es quizá, no se trunca.

Agilidad criptográfica desde el día uno

Ninguna función resumen dura para siempre. Las que se consideraban sólidas hace veinte años están rotas, y las de hoy se romperán en algún momento que nadie sabe fechar. La pregunta no es si habrá que cambiar sino qué habrá que hacer el día que toque, y la respuesta depende por completo de una decisión de formato que se toma al principio y que cuesta dos bytes.

La técnica es la que ya apareció al hablar de direccionamiento por contenido: el nombre no es el resumen desnudo sino un valor precedido de unos bytes que identifican qué función lo produjo y qué longitud tiene. Con ese prefijo, un sistema puede empezar a emitir nombres nuevos con otra función mientras sigue resolviendo los antiguos, sin cortes y sin ambigüedad. Sin él, el día que toque cambiar tendrás un almacén lleno de cadenas de treinta y dos bytes sobre las que ninguna herramienta puede decidir qué función las generó, y la única salida será una migración total con parada.

// Un nombre autodescriptivo cuesta dos bytes y se decide una sola vez
const nombre = { fn: 0x12, len: 32, digest: bytes };  // fn identifica la funcion
// Emitir con otra funcion no rompe nada: los antiguos siguen siendo resolubles
function resolver(n) {
  const f = FUNCIONES[n.fn];
  if (!f) throw new Error('funcion desconocida');     // fallo explicito, no silencioso
  return f;
}

Hay sin embargo un agravante específico de los grafos de resúmenes que conviene entender antes de confiarse. En un almacén plano, cambiar de función permite una migración perezosa: los objetos viejos conservan su nombre viejo y los nuevos nacen con el nuevo. En un árbol o un grafo dirigido acíclico eso no funciona, porque el nombre de un nodo interno se calcula a partir de los nombres de sus hijos: cambiar la función en las hojas cambia todos los nodos internos, cambia la raíz y cambia por tanto todas las referencias externas que citaban esa raíz. La migración es total por construcción y el prefijo no la evita; lo que hace el prefijo es permitir que las dos versiones coexistan mientras dura, que es la diferencia entre una transición y una parada.

🏷️

Nombre autodescriptivo

Función y longitud viajan con el resumen, de modo que ninguna herramienta tiene que adivinar qué lo produjo.

🪜

Coexistencia, no sustitución

Emitir con la nueva y resolver con ambas durante la transición, con una fecha de retirada escrita en algún sitio.

🕸️

En un grafo la migración es total

Cambiar la función en las hojas reescribe todos los nodos internos y todas las raíces citadas fuera.

🚫

Sin negociación con el par

La política de funciones aceptadas es local y ordenada; quien te envía datos no elige con qué los verificas.

La cuarta tarjeta señala el modo de fallo clásico de la agilidad mal entendida, y es lo bastante frecuente como para merecer su párrafo. Si la superficie de negociación permite que el otro extremo indique qué función usar, has construido un ataque de degradación: basta con que una de las funciones aceptadas se debilite para que el adversario la seleccione. La forma correcta es que la lista de funciones aceptadas sea una política local, ordenada por preferencia, con una fecha de retirada para cada entrada, y que un nombre con una función no aceptada falle de forma explícita en lugar de resolverse por otro camino. Un nombre que llegue con una función desconocida es un error, nunca una advertencia.

El procedimiento de decisión

Con todo lo anterior, la elección se convierte en un procedimiento y deja de ser una opinión. Primero, clasifica cada identificador de tu sistema en publicado o interno, y no aceptes la categoría intermedia: si dudas, es publicado, porque lo que sale nunca vuelve. Segundo, para los publicados elige la función que tus verificadores ya tienen instalada, y si hay obligación normativa la decisión ya está tomada. Tercero, para los internos mide en tu hardware real, incluyendo el dispositivo más modesto de tu parque, en monohilo y en multihilo, y comprueba si tu procesador tiene instrucciones dedicadas de SHA antes de sacar conclusiones. Cuarto, decide si necesitas verificación parcial, y si la necesitas la decisión de estructura ya está tomada porque un diseño secuencial no la ofrece a ningún precio. Quinto, fija la longitud contando todos los sitios donde se multiplica y no truncando por comodidad. Y sexto, escribe el prefijo de función y longitud en el formato antes de guardar el primer objeto, porque ese es el único paso de la lista cuyo coste se multiplica por mil si se pospone.

# Mide en tu hardware, no en el articulo de otro, y en los dos regimenes
time openssl sha256 /tmp/prueba.bin
time b3sum --num-threads 1 /tmp/prueba.bin
time b3sum /tmp/prueba.bin
El formato de nombre sobrevive a la función, a la biblioteca y al equipo

Si esta lección tuviera que reducirse a una sola idea, no sería ninguna comparación entre funciones, porque esa comparación caduca. Sería la observación de que los objetos de tu sistema tienen vidas útiles de duraciones muy distintas y casi nadie las ordena antes de decidir. La biblioteca que calcula el resumen se sustituye en una tarde. La función resumen dura entre una y dos décadas, que es más de lo que dura la mayoría de los productos. El formato en el que escribes los nombres dura tanto como los datos, y los datos de un sistema local-first están, por definición del término, en discos que no controlas y que nadie migrará por ti. De esa ordenación sale una asimetría brutal que conviene enunciar sin suavizar: las decisiones más difíciles de revertir son también las más baratas de tomar bien, y por eso se toman mal. Añadir dos bytes de prefijo al nombre no cuesta nada el primer día y es imposible el día diez mil, cuando esos nombres están en copias de seguridad, en manifiestos firmados, en referencias internas de un grafo y en la memoria de otras implementaciones que leen tu formato. Elegir treinta y dos bytes en lugar de dieciséis cuesta espacio medible y recuperable; elegir dieciséis en lugar de treinta y dos cuesta una reescritura completa del grafo el día que aparezca un adversario que controle las dos entradas. Hay una segunda lectura de esto que va más allá de los hashes y que conviene llevarse del nivel entero. Un formato de datos es la única parte de tu arquitectura que no puedes refactorizar, porque no es tuya en cuanto la publicas. Todo lo demás —el lenguaje, la biblioteca, el esquema interno, el algoritmo, hasta el modelo de datos— admite ser cambiado por un equipo con tiempo. El formato no, porque vive en máquinas ajenas y en ficheros que nadie va a abrir hasta dentro de años. Diseñar sabiendo eso significa gastar en el formato una desproporción de cuidado respecto al resto, incluir en él los campos que no necesitas todavía —la función, la longitud, la versión— y aceptar que ese gasto parecerá excesivo durante mucho tiempo y luego, exactamente una vez, salvará el sistema.

⚔️ Decide y deja la decisión escrita
  1. Haz el inventario de identificadores de tu sistema y clasifica cada uno en publicado o interno, justificando los dudosos.
  2. Mide las dos funciones en tu máquina de desarrollo y en el dispositivo más modesto de tu parque, en monohilo y en multihilo.
  3. Cuenta los bytes que ocupan los nombres en tu índice, en tus nodos internos y en un mensaje de sincronización típico.
  4. Añade el prefijo de función y longitud a tu formato de nombre y comprueba qué se rompe al introducirlo hoy.
  5. Escribe el plan de migración a otra función suponiendo que la actual cae dentro de tres años, con la parte del grafo que hay que reescribir.
  6. Redacta la política local de funciones aceptadas con su orden de preferencia y su fecha de retirada, y haz que lo desconocido falle.