Qué se le pide a un hash aquí
Un sistema local-first exige a su función resumen las resistencias clásicas y además una velocidad que decide cuántas de esas garantías acaban estando encendidas de verdad.
Cuando una arquitectura llega al punto de elegir función resumen, la conversación se reduce casi siempre a una pregunta mal planteada: cuál es más segura. Es mal planteada porque dentro del conjunto de funciones que hoy se consideran sólidas la diferencia de seguridad práctica es aproximadamente cero, ninguna tiene un ataque que te afecte y ninguna lo tendrá durante la vida útil de tu producto. La pregunta que sí discrimina aparece en cuanto miras dónde se ejecuta la función dentro de un sistema local-first, y la respuesta es incómoda: en el camino crítico de cada escritura, de cada sincronización y de cada lectura verificada. Un editor que guarda cada pocos segundos, un almacén que trocea, nombra y deduplica cada bloque, un cliente que comprueba todo lo que recibe porque decidió no confiar en el transporte. En ese régimen el coste por byte deja de ser una curiosidad de laboratorio y se convierte en el factor que determina qué garantías puedes permitirte tener activas todo el tiempo. Esta lección redacta el pliego de condiciones completo antes de mirar ninguna función concreta, porque quien elige sin ese pliego elige por moda.
- Distinguir las tres resistencias clásicas y saber cuál protege qué dentro de un almacén direccionado por contenido.
- Entender por qué la resistencia a colisiones importa aunque tu sistema no parezca tener adversarios.
- Situar la velocidad como requisito de corrección y no como preferencia, con la contabilidad que lo justifica.
- Reconocer las propiedades que no hay que pedirle a esta función y qué herramienta corresponde a cada una.
Las tres resistencias y cuál te va a doler
Las definiciones clásicas se enuncian en tres líneas y conviene tenerlas exactas antes de razonar sobre ellas. Resistencia a preimagen: dado un resumen, nadie sabe construir una entrada que lo produzca. Resistencia a segunda preimagen: dada una entrada concreta, nadie sabe construir otra distinta con el mismo resumen. Resistencia a colisiones: nadie sabe construir un par cualquiera de entradas distintas con el mismo resumen, con libertad total para elegir ambas.
La diferencia de coste entre la tercera y las dos primeras no es de matiz. Para una función de salida de n bits, buscar una preimagen o una segunda preimagen por fuerza bruta cuesta del orden de dos elevado a n intentos, mientras que encontrar una colisión cualquiera cuesta del orden de dos elevado a n partido por dos, por el argumento del cumpleaños. Una función de doscientos cincuenta y seis bits ofrece por tanto ciento veintiocho bits de resistencia a colisiones y doscientos cincuenta y seis frente a segunda preimagen. Ese factor de dos en el exponente es la razón de que las salidas se dimensionen por el escenario de colisión y no por el otro.
Ahora la parte que importa aquí, que es qué protege cada una en un almacén direccionado por contenido. La resistencia a segunda preimagen protege al lector que ya tiene un nombre legítimo: nadie puede entregarle bytes distintos para un identificador que obtuvo honestamente, ni un servidor comprometido, ni un intermediario, ni un disco que se degradó. Es la propiedad que convierte la comprobación en la lectura en algo con sentido.
La resistencia a colisiones protege un escenario distinto y menos evidente: aquel en el que el adversario controla las dos entradas. La objeción habitual es que ese adversario suena rebuscado, y en un sistema local-first no lo es en absoluto. Ocurre en cuanto hay un almacén compartido que deduplica: alguien sube un bloque cuyo resumen coincide con el de un bloque que tú subirás más tarde, el almacén observa que el nombre ya existe, no guarda tus bytes, y a partir de ese momento lo que te devuelve es lo del otro. Ocurre también en cuanto se firma la raíz de un árbol de resúmenes, porque una firma sobre un resumen colisionado vale igual para el documento inocente y para el gemelo preparado.
flowchart TB A[Adversario controla una sola entrada] --> SP[Necesitas segunda preimagen] B[Adversario controla las dos entradas] --> RC[Necesitas resistencia a colisiones] SP --> L[Protege la lectura verificada] RC --> D[Protege deduplicacion y firmas sobre la raiz] style RC fill:#f38ba8,color:#11111b style SP fill:#a6e3a1,color:#11111b
La lección histórica está disponible y es barata de aprender: las funciones que han caído en producción cayeron primero por colisiones, no por preimagen, y siguieron siendo resistentes a preimagen mucho después de ser inservibles. Un sistema que solo necesitase segunda preimagen podría haber seguido usando funciones ya rotas durante años; ninguno de los sistemas que nos interesan está en ese caso, porque todos deduplican, firman raíces o aceptan contenido de terceros.
Dentro de la resistencia a colisiones hay una gradación que el vocabulario habitual esconde y que decide si un ataque es una curiosidad o una catástrofe. Una colisión de prefijo idéntico produce dos mensajes que empiezan igual y difieren en un bloque preparado, y explotarla exige que el formato de tu dato admita basura arbitraria en medio sin que nadie se dé cuenta. Una colisión de prefijo elegido es mucho peor: el atacante fija de antemano dos comienzos distintos y arbitrarios, por ejemplo dos documentos con contenidos completamente diferentes, y construye los sufijos que igualan los resúmenes. La segunda es la que convierte una función en inservible para firmar, y la historia reciente muestra que suele llegar unos años después de la primera. Cuando leas que una función tiene colisiones, la pregunta que discrimina es de qué tipo.
Hay una última precisión sobre el dimensionado que evita discusiones estériles. Ciento veintiocho bits de resistencia a colisiones no significan que haya que preocuparse cuando el almacén tenga dos elevado a ciento veintiocho objetos, sino que un atacante dedicado necesita del orden de esa cantidad de operaciones. Contra colisiones accidentales el margen es absurdo: con billones de bloques la probabilidad sigue siendo despreciable frente a la de un fallo de memoria no detectado en la misma máquina. El dimensionado se decide, por tanto, mirando al adversario y no al inventario.
La velocidad como requisito de corrección
La formulación que hay que interiorizar es esta: una propiedad que no puedes permitirte comprobar es una propiedad que no tienes. Si verificar en cada lectura cuesta una fracción perceptible del tiempo de lectura, alguien añadirá una opción para desactivarlo, esa opción se activará por defecto en el perfil de producción, y el sistema conservará el vocabulario de la verificación sin conservar la verificación. Este patrón es tan frecuente que conviene tratarlo como el modo de fallo por defecto y diseñar contra él.
La contabilidad concreta se hace en tres frentes que no se solapan. El primero es la escritura: cada guardado rehashea los trozos modificados y recalcula la rama del árbol que los cubre. El segundo es el arranque en frío o la comprobación periódica de integridad, que pasa el conjunto entero de datos por la función. El tercero es la sincronización, donde cada bloque recibido se verifica antes de aceptarse, y cada bloque enviado se nombra antes de ofrecerse.
// Contabilidad de un guardado incremental sobre un arbol de resumenes
const hojas = 800; // 200 MB en trozos de 256 KiB
const tocados = 3; // lo que cambia un guardado tipico
const profundidad = Math.ceil(Math.log2(hojas));// 10 niveles hasta la raiz
const bytesHoja = tocados * 262144; // 768 KiB de datos rehasheados
const bytesRama = tocados * profundidad * 64; // 1.9 KiB de nodos internos
// Los nodos internos son ruido: el coste vive entero en las hojas
El detalle que revela el número anterior es que el coste incremental es minúsculo y el coste total no lo es. Un guardado toca menos de un megabyte; una verificación completa del espacio de trabajo toca doscientos. Y la verificación completa no es un lujo: es lo que se ejecuta al abrir el proyecto tras una caída, al restaurar una copia, al migrar de dispositivo y cada vez que el usuario duda de si sus datos siguen intactos. La diferencia entre una función que procesa un gigabyte por segundo en un núcleo y una que procesa varios usando todos los núcleos decide si esa comprobación tarda lo que dura un parpadeo o lo que dura una pausa incómoda, y esa diferencia es la que determina si la comprobación se ofrece siempre o se esconde detrás de un botón.
Conviene además notar que el trabajo por byte no se paga una vez sino tantas veces como capas lo pidan, y que esas capas rara vez se coordinan. El troceador nombra cada bloque. El índice de deduplicación comprueba si ese nombre ya estaba. El transporte verifica lo que recibe. El barrido periódico de integridad vuelve a pasar por todo. Y si además hay cifrado en reposo, la capa correspondiente añade su propio recorrido. Un dato puede así atravesar la función tres o cuatro veces en su vida sin que nadie haya escrito en ninguna parte que eso ocurra, porque cada capa se diseñó por separado y cada una consideró su recorrido razonable.
Los cuatro puntos donde se paga
En cada escritura
Trocear, nombrar y actualizar la rama del árbol es trabajo por byte que se paga en la misma latencia que el usuario percibe como guardar.
En cada sincronización
Todo bloque que entra se comprueba contra su nombre antes de aceptarse, y todo bloque que sale se nombra antes de ofrecerse.
En el arranque en frío
Restaurar o auditar pasa el conjunto entero por la función, y ahí el coste por byte se multiplica por todo lo que el usuario tiene.
En la deduplicación
Cada bloque candidato se nombra antes de saber si ya estaba, de modo que se hashea incluso el trabajo que finalmente no se guarda.
Hay además una consecuencia energética que en móvil no es retórica. El trabajo por byte se paga en ciclos y los ciclos se pagan en batería, y un sistema que rehashea de forma agresiva en un dispositivo alimentado por batería obtiene un castigo doble: consume más y además compite con el hilo que dibuja la interfaz. Que la función sea paralelizable ayuda al primer problema y agrava el segundo si se ejecuta en el hilo principal, lo que anticipa una decisión de arquitectura que la tercera lección retomará.
// El presupuesto que casi nadie escribe: bytes por operacion y operaciones por dia
const guardadosPorHora = 120; // un guardado automatico cada 30 segundos
const bytesPorGuardado = 786_432; // tres trozos de 256 KiB
const bytesDiarios = guardadosPorHora * 8 * bytesPorGuardado; // jornada de 8 horas
const arranques = 3; // aperturas frias al dia
const bytesArranque = arranques * 209_715_200;
// El arranque en frio domina: verificar entero pesa mas que todo lo que se escribio
El resultado de esa cuenta es contraintuitivo la primera vez y conviene detenerse en él, porque cambia dónde se optimiza. La escritura, que es lo que el usuario percibe como lento, mueve muy pocos bytes por la función. El arranque en frío, que se percibe como un tiempo muerto inevitable y del que nadie se queja explícitamente, mueve el conjunto entero. Optimizar el camino de escritura es por tanto casi siempre optimizar la parte pequeña, y la decisión de función se juega en la parte que nadie está mirando.
Lo que a esta función no hay que pedirle
Cuatro peticiones equivocadas aparecen una y otra vez, y las cuatro tienen la misma forma: se le pide a la función resumen una garantía que pertenece a otra herramienta. Conviene desmontarlas de una vez.
La primera es usarla para contraseñas. Es exactamente el requisito contrario: aquí quieres máxima velocidad, y en almacenamiento de contraseñas quieres lentitud calibrada y coste en memoria para que el atacante no pueda probar miles de millones de candidatos. La documentación oficial de BLAKE3 lo dice sin rodeos, señalando que no es un algoritmo de hash de contraseñas precisamente porque está diseñado para ser rápido, y recomienda Argon2 para ese caso. Lo mismo vale para SHA-256 desnudo.
La segunda es esperar confidencialidad. Un resumen no cifra: no hay clave, no hay operación inversa y no protege el secreto de nada. Si el espacio de entradas posibles es pequeño o adivinable, el resumen las revela por enumeración, y por eso nombrar por contenido un dato de baja entropía equivale a publicarlo ante quien pueda comprobar candidatos.
La tercera es confundir integridad con autenticidad. El resumen dice que estos bytes son los que nombraste, no dice de quién vienen. Toda la confianza se desplaza al canal por el que obtuviste el nombre, que es una ventaja enorme porque concentra el problema en un punto pequeño, pero solo si se reconoce que el punto sigue ahí y que taparlo exige una firma o un código de autenticación sobre la raíz.
Una función con estructura de relleno iterado, como SHA-256, tiene una propiedad incómoda: quien conoce el resumen de un mensaje y su longitud puede calcular el resumen de ese mismo mensaje seguido de un relleno y de un sufijo elegido, sin conocer el mensaje original. Eso invalida la construcción tentadora de calcular el resumen de la clave concatenada con el mensaje como código de autenticación, y es la razón histórica de que exista HMAC. La documentación de BLAKE3 declara explícitamente que su diseño es seguro frente a extensión de longitud, a diferencia de SHA-2, y por eso ofrece un modo con clave nativo que no necesita envoltorio. Es la primera diferencia con consecuencias de ingeniería que veremos entre las dos familias.
La cuarta petición equivocada es más sutil y merece atención: pedirle que sea un identificador opaco. Un nombre por contenido no es opaco por construcción, porque contenidos idénticos producen nombres idénticos y eso es precisamente lo que hace posible la deduplicación. En un almacén multiusuario esa misma propiedad permite a un tercero confirmar si un contenido concreto está presente sin llegar a leerlo. No es un defecto de la función: es la consecuencia inevitable de haber elegido nombrar por contenido, y la única forma de eliminarla es renunciar a la deduplicación entre usuarios o derivar las claves del propio contenido.
// Cuatro peticiones equivocadas y la herramienta que corresponde a cada una
const errores = {
contrasenas: 'usa una funcion lenta con coste en memoria, no una rapida',
confidencialidad: 'usa cifrado con clave, el resumen no oculta nada',
autenticidad: 'usa firma o codigo de autenticacion sobre la raiz',
opacidad: 'no la hay: nombrar por contenido revela la presencia del contenido',
};
Las cuatro comparten la misma raíz y por eso conviene recordarlas juntas: la función resumen resuelve un problema y solo uno, que es asignar a unos bytes un nombre corto que cualquiera pueda recalcular. Todo lo que se le pida por encima de eso lo hará mal, y lo hará mal en silencio, que es la peor forma de hacerlo. El síntoma de que estás pidiéndole demasiado es que en tu explicación aparece un verbo que no es nombrar ni comprobar.
El error de encuadre que domina esta discusión es tratar seguridad y rendimiento como dos ejes independientes entre los que se negocia. No lo son, y verlo cambia la decisión. La resistencia criptográfica determina qué eres capaz de afirmar sobre unos bytes sin confiar en nadie, y en el conjunto de funciones vivas esa capacidad es esencialmente la misma en todas: el techo está saturado y ya no discrimina. El coste por byte determina otra cosa, que casi nunca se enuncia: con qué frecuencia estás dispuesto a ejercer esa capacidad. Y esa segunda variable no se negocia una vez en una reunión de diseño, se negocia cada día, en cada revisión de rendimiento, en cada informe de un usuario que dice que abrir el proyecto tarda demasiado. Un sistema que verifica en cada lectura y otro que verifica solo al descargar tienen la misma criptografía sobre el papel y garantías completamente distintas en la práctica, porque el segundo acepta como buena cualquier corrupción posterior a la descarga. La forma útil de leer la elección de función es esta: no estás eligiendo cuánta seguridad tiene tu sistema, estás eligiendo cuánta verificación puedes dejar encendida por defecto sin que nadie la apague. Toda garantía que dependa de una opción configurable acabará apagada en algún despliegue, porque quien la apaga tiene un problema concreto de latencia delante y la garantía que sacrifica es abstracta y silenciosa. Por eso el argumento de velocidad, que suena a optimización prematura cuando se enuncia mal, es en realidad un argumento sobre qué propiedades sobrevivirán al contacto con producción. Las dos lecciones siguientes examinan cómo dos diseños distintos responden a este mismo pliego, y la cuarta muestra que el diseño en árbol no solo va más rápido sino que hace posible una garantía que la estructura secuencial no puede ofrecer a ningún precio.
- Enumera todos los puntos de tu arquitectura donde se calcula un resumen y clasifícalos en escritura, sincronización, verificación y deduplicación.
- Para cada punto, decide si el adversario relevante controla una entrada o las dos, y anota qué resistencia necesitas ahí.
- Estima los bytes que pasan por la función en un guardado típico, en un arranque en frío y en una sincronización completa.
- Busca en tu código cualquier opción que permita saltarse la verificación y averigua en qué entornos está activa hoy.
- Comprueba si en algún sitio usas un resumen desnudo como código de autenticación y sustitúyelo por HMAC o por un modo con clave.
- Escribe en una página qué garantía concreta perdería tu sistema si la verificación en lectura se desactivara mañana sin avisar.