← All posts

HMAC se comió mi autocompletado: búsqueda predictiva sobre emails cifrados

Authagonal·July 22, 2026
encryptionblind-indexsearchvaulthmacazure-tablepii

Ciframos la PII de los usuarios en reposo con claves por tenant, de modo que un volcado filtrado de la base de datos no exponga nada útil. La columna de email es texto cifrado. Los teléfonos, los nombres, los atributos personalizados: texto cifrado. Estamos orgullosos de esto. Es un argumento de venta.

Y el día que entró en producción, el cuadro de búsqueda del panel de administración dejó de autocompletar sin hacer ruido.

Sin errores. Sin una sola línea de log. Al escribir ali en la búsqueda de usuarios, el administrador del tenant que antes veía aparecer [email protected] tras tres pulsaciones ahora no veía... nada, a menos que escribiera la dirección de correo completa, exactamente. La búsqueda no se había roto: se había degradado en silencio de "empieza por" a "es igual a", y nada en el sistema consideró que eso mereciera mencionarse.

Esta es la historia de cómo recuperamos el autocompletado sobre datos que nos negamos a almacenar en texto plano, y de las tres trampas en las que caímos por el camino. La criptografía resultó ser la parte fácil.

Por qué el cifrado se come el autocompletado

Con texto plano, la búsqueda por prefijo es justo para lo que existen las bases de datos. Mantén un índice ordenado por email y starts with "ali" es un escaneo de rango: todo lo que sea >= "ali" y < "alj". Barato, obvio, resuelto.

Cifra la columna y el índice ordenado desaparece. El reemplazo estándar es un blind index (índice ciego): junto al texto cifrado se almacena un HMAC con clave del valor, y los usuarios se buscan recomputando el HMAC del término de búsqueda. HMAC(key, "[email protected]") es determinista, así que la búsqueda por coincidencia exacta funciona perfectamente, y el índice no filtra nada legible, porque sin la clave por tenant no se puede computar un digest con el que comparar.

Pero fíjate en para qué sirve HMAC. Todo su objetivo de diseño es que entradas similares produzcan salidas sin relación alguna: cambia un bit y obtienes un digest completamente distinto. HMAC("ali") y HMAC("alistair") no tienen nada que ver entre sí. La propiedad que hace que el blind index sea seguro de filtrar es precisamente la propiedad que lo incapacita para responder "empieza por". El orden es filtración. Un blind index no rompe la búsqueda por prefijo por accidente; la rompe por principio.

Así que el cuadro de búsqueda se degradó a coincidencia exacta, en silencio, porque la coincidencia exacta era la única pregunta que el índice aún podía responder.

El diseño: indexar cada prefijo como un valor propio

Si el índice solo puede responder "es igual a", entonces convierte "empieza por" en "es igual a".

Cada prefijo de la parte local normalizada del email (lo que va antes de la @) recibe su propia fila de blind index. Para [email protected], eso son filas para al, ali, alis, alist, etcétera: PartitionKey = HMAC(prefix), RowKey = el id del usuario. Ahora "empieza por ali" es una búsqueda por coincidencia exacta sobre HMAC("ali"): una sola consulta puntual, sin necesidad de orden. Nuestra búsqueda por nombre ya funcionaba así por la misma razón; el email simplemente se sumó.

Dos constantes lo mantienen a raya. Los prefijos empiezan en 2 caracteres (las búsquedas de un solo carácter nunca fueron útiles y duplican las filas) y llegan como máximo a 16 (lo que acota el fan-out por email; una consulta más larga simplemente coincide por sus primeros 16 caracteres, y el puñado de candidatos se filtra tras el descifrado). Así que cada email cuesta como mucho 15 filas de índice: se escriben al crear, se mueven al cambiar el email y se eliminan al borrar.

Ese es el trato en una frase: recompras el orden que te negaste a filtrar, y lo pagas en fan-out de escritura. El almacenamiento y las escrituras son baratos; la estructura filtrada no lo es. Es un buen trato.

Una sutileza en la ruta de mover-al-cambiar se ganó su propio comentario en el código: las filas de prefijos se indexan por la parte local, así que la reescritura tiene que dispararse cuando la parte local cambia, con independencia del dominio. Un cambio de nombre dentro del mismo dominio, [email protected][email protected], parece "el dominio no cambió, sáltate el trabajo de índice" para una guarda escrita pensando en el índice de dominios, y dejaría las viejas filas de prefijos apuntando al usuario renombrado para siempre.

Y para ser honestos sobre lo que construimos: este índice filtra deliberadamente la igualdad de prefijos; un atacante con la tabla puede ver que dos usuarios comparten un prefijo de email de 3 caracteres, aunque no cuál es. El cifrado con capacidad de búsqueda nunca elimina la filtración; te permite elegirla, conscientemente, según la forma de cada consulta. La igualdad y la igualdad de prefijos son la filtración que elegimos. Ese encuadre (elige tu filtración y luego diseña todo lo demás a su alrededor) es toda la disciplina.

El diseño funcionó. Entonces empezaron los problemas de sistemas.

Trampa uno: la clave que no se podía crear

Los blind indexes necesitan una clave HMAC por tenant, aprovisionada en el motor transit de Vault igual que nuestras claves de cifrado. Crear una devolvía un 500: invalid key size for HMAC key.

Vault exige un key_size explícito para las claves de tipo hmac (de 32 a 512 bytes), mientras que los tipos de tamaño fijo (aes256-gcm96, ecdsa-p256) lo prohíben. Nuestra llamada de creación de claves enviaba solo {"type":"hmac"}. Un campo que faltaba.

Y esta es la razón por la que fue una trampa y no un simple reporte de bug: cada operación de tokenización lanzaba una excepción, y la ruta de login tokeniza, porque encontrar a un usuario por email al iniciar sesión pasa por el mismo blind index que la búsqueda de administración. Habilitar el cifrado no rompió la búsqueda. Rompió el login. La funcionalidad cuyo argumento de venta es "tus usuarios están más seguros" tumbó el inicio de sesión la primera vez que se habilitó en dev. El arreglo es key_size=32 (HMAC-SHA256) para las claves hmac, omitido para los tipos de tamaño fijo, y una regla permanente: haz una prueba de humo de la ruta de tokenización contra un Vault real antes de activar el cifrado en cualquier sitio. Los mocks no validan los payloads de creación de claves.

Trampa dos: la actualización que podía dejar tirado a un usuario

Cambiar un email implica mantenimiento del índice: eliminar las filas viejas, escribir las nuevas. Nuestra primera implementación lo hacía en ese orden: borrar y luego escribir. Natural, prolijo, incorrecto.

Cada escritura de filas nuevas involucra ahora a Vault (para computar las PartitionKeys HMAC). Borra primero las filas viejas, y un tropiezo de Vault durante la escritura deja al usuario sin las filas de búsqueda viejas ni las nuevas. Existe, cifrado, en la tabla, y nada puede encontrarlo. Incluido el login. Eso no es una búsqueda degradada; es un usuario bloqueado fuera, hasta que algún reindexado futuro pase por ahí.

El arreglo es cuestión de orden, no de manejo de errores: escribir antes de borrar, en todos los lugares donde se mantiene el índice. Un fallo entre los dos pasos ahora deja una fila obsoleta de más (inofensiva, y que se limpia de forma perezosa) en lugar de una fila que falta. El modo de fallo pasó de "usuario inalcanzable" a "una fila redundante", gratis. Cuando una ruta de escritura involucra una dependencia remota, elige el orden de pasos cuyo estado a medio terminar puedas tolerar.

Trampa tres: la partición que se comió una importación

También mantenemos un blind index de dominios (HMAC(domain) → miembros) para que "todos los de acme.com" sea una sola búsqueda. El hashing determinista tiene una consecuencia que nadie pone en precio hasta una importación: todos los usuarios de un mismo dominio caen en una sola partición. Una partición de Azure Table admite aproximadamente 2.000 operaciones por segundo. Una importación desde Auth0 de 50k usuarios de un único dominio canalizó cada escritura del índice de dominios exactamente hacia ese cuello de botella.

El arreglo es el bucketing: los miembros se reparten entre 16 particiones según un hash del id de usuario, y las lecturas por dominio se abren en abanico sobre los buckets: acotadas, y lo bastante raras como para no importar. Dos detalles concentraron la lección. El hash de bucket es un FNV-1a escrito a mano, porque string.GetHashCode de .NET es deliberadamente inestable entre procesos: haz buckets con él y el proceso de mañana computará un bucket distinto para el mismo usuario y no podrá encontrar la fila que se supone que debe borrar. Y la ruta de lectura sigue barriendo las particiones legacy sin buckets, de modo que las filas existentes siguieron siendo localizables sin backfill forzado: las escrituras nuevas se distribuyen de inmediato y las filas viejas migran cuando el usuario vuelve a ser tocado.

La lección

Nada en esta historia es criptografía novedosa. HMAC tiene décadas; "hashea el valor, indexa el hash" cabe en una frase. Todo lo que de verdad nos costó fue trabajo de sistemas: qué formas de consulta necesita realmente el producto (igualdad, prefijo, dominio; cada una recibió su propio índice, porque un blind index responde exactamente una pregunta); cómo se aprovisionan las claves y qué pasa en la ruta de login cuando no se aprovisionan; en qué orden ocurren las escrituras del índice cuando hay un KMS remoto en medio; y dónde concentra el hashing determinista una carga que el texto plano nunca concentró.

El cifrado con capacidad de búsqueda se vende como una funcionalidad criptográfica. Constrúyelo y descubrirás que es una funcionalidad de sistemas distribuidos con disfraz de criptografía. El cuadro de búsqueda vuelve a autocompletar (ali encuentra a Alistair tras tres pulsaciones) y un volcado robado de la misma tabla muestra digests HMAC repartidos en dieciséis buckets, es decir: nada. Las dos cosas a la vez fueron siempre el objetivo.