<!-- Canonical: https://lucioduran.com/es/blog/zero-knowledge-proofs-web3-authentication
Language: es
Content-SHA256: 71b6fba89beb9befbd27bdc5677c1a165503cdb20efdb61e2ef020c73017a03b
-->

Cryptography

# Zero-Knowledge Proofs para Autenticación: Verificación de Identidad con Preservación de Privacidad

2025-11-049 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication>)

## La Idea Central en 30 Segundos

Una prueba de conocimiento cero te permite probar que una declaración es verdadera sin revelar los datos subyacentes. Para autenticación:

```
Auth tradicional:
 Cliente envía password → Servidor checkea hash → Servidor conoce el password

Auth ZK:
 Cliente genera prueba("Conozco un password que hashea a X") →
 Servidor verifica prueba → Servidor no aprende NADA excepto "sí, lo sabe"
```

El servidor nunca ve el password. Nunca ve valores intermedios. Solo aprende un único bit de información: la prueba es válida o no lo es.

Esto no es teórico. La matemática es sólida desde los años 80. Lo nuevo es que finalmente tenemos sistemas de prueba lo suficientemente eficientes para hacer esto práctico en un navegador web.

## Cómo Funcionan los zk-SNARKs (Sin los Papers Académicos)

SNARK significa "Succinct Non-interactive ARgument of Knowledge." Déjenme desglosar qué significa cada palabra en la práctica:

- **Succinct (Sucinto)**: La prueba es pequeña (192-512 bytes) sin importar cuán compleja sea la declaración. Verificar la prueba es rápido (milisegundos).
- **Non-interactive (No interactivo)**: El prover manda la prueba al verifier en un solo mensaje. Sin ida y vuelta.
- **ARgument of Knowledge (Argumento de Conocimiento)**: El prover realmente conoce los datos secretos, no puede falsificar la prueba sin ellos (bajo supuestos de dureza computacional).

El proceso tiene tres fases:

### 1. Definición del Circuito

Expresars tu lógica de autenticación como un circuito aritmético, un grafo dirigido acíclico de compuertas de suma y multiplicación sobre un campo finito. A continuación, es donde entra [Circom](<https://docs.circom.io/>).

```circom
// credential_auth.circom
pragma circom 2.1.0;

include "circomlib/circuits/poseidon.circom";
include "circomlib/circuits/comparators.circom";

template CredentialAuth() {
 // Inputs privados (conocidos solo por el prover)
 signal input secret; // credencial secreta del usuario
 signal input salt; // salt random

 // Inputs públicos (conocidos por prover y verifier)
 signal input credentialHash; // Poseidon(secret, salt), registrado en servidor
 signal input nullifier; // previene replay
 signal input domainSeparator; // constante específica del servicio

 // Verificar la credencial
 component hasher = Poseidon(2);
 hasher.inputs[0] <== secret;
 hasher.inputs[1] <== salt;
 hasher.out === credentialHash;

 // Computar nullifier (previene replay de prueba)
 component nullifierHasher = Poseidon(2);
 nullifierHasher.inputs[0] <== secret;
 nullifierHasher.inputs[1] <== domainSeparator;
 nullifierHasher.out === nullifier;
}

component main {public [credentialHash, nullifier, domainSeparator]} = CredentialAuth();
```

Este circuito dice: "Conozco un ` secret ` y ` salt ` que hashean a ` credentialHash `, y aquí se tiene un ` nullifier ` derivado de mi secreto y tu domain separator." El servidor almacena ` credentialHash ` durante el registro y checkea el ` nullifier ` para prevención de replay.

### 2. Trusted Setup

Para Groth16, se necesita una ceremonia de trusted setup que genera la proving key y verification key para este circuito específico:

```bash
# Compilar el circuito
circom credential_auth.circom --r1cs --wasm --sym -o build/

# Iniciar el trusted setup (ceremonia Powers of Tau)
snarkjs powersoftau new bn128 14 pot14_0000.ptau
snarkjs powersoftau contribute pot14_0000.ptau pot14_0001.ptau \
 --name="Primera contribución" -e="entropía random aquí"
snarkjs powersoftau prepare phase2 pot14_0001.ptau pot14_final.ptau

# Generar claves específicas del circuito
snarkjs groth16 setup build/credential_auth.r1cs pot14_final.ptau \
 credential_auth_0000.zkey
snarkjs zkey contribute credential_auth_0000.zkey credential_auth_final.zkey \
 --name="Contribución del circuito" -e="más entropía random"

# Exportar verification key (esto va al servidor)
snarkjs zkey export verificationkey credential_auth_final.zkey verification_key.json
```

El trusted setup es la mayor limitación práctica de Groth16. Si se modifica el circuito (incluso agregando un constraint), se necesita una nueva ceremonia. Por eso el setup universal de Plonk es atractivo para sistemas de auth en iteración rápida.

### 3. Generación y Verificación de Pruebas

Del lado del cliente (en el browser via WASM):

```javascript
import * as snarkjs from "snarkjs";

async function generateAuthProof(secret, salt, credentialHash, domainSeparator) {
 // Computar el nullifier client-side
 const nullifier = poseidonHash([secret, domainSeparator]);

 const input = {
 secret: secret,
 salt: salt,
 credentialHash: credentialHash,
 nullifier: nullifier,
 domainSeparator: domainSeparator,
 };

 // Generar la prueba (toma ~1-2 segundos en browser)
 const { proof, publicSignals } = await snarkjs.groth16.fullProve(
 input,
 "/circuits/credential_auth.wasm",
 "/circuits/credential_auth_final.zkey"
 );

 return { proof, publicSignals };
}

// Enviar al servidor
const { proof, publicSignals } = await generateAuthProof(
 mySecret, mySalt, registeredHash, "app.example.com"
);

await fetch("/api/auth/verify", {
 method: "POST",
 body: JSON.stringify({ proof, publicSignals }),
});
```

Del lado del servidor:

```javascript
import * as snarkjs from "snarkjs";
import vkey from "./verification_key.json";

async function verifyAuthProof(proof, publicSignals) {
 // publicSignals[0] = credentialHash
 // publicSignals[1] = nullifier
 // publicSignals[2] = domainSeparator

 // Verificar la prueba ZK
 const isValid = await snarkjs.groth16.verify(vkey, publicSignals, proof);
 if (!isValid) return { authenticated: false };

 // Checkear que el credential hash existe en nuestra DB
 const credential = await db.findOne({
 credentialHash: publicSignals[0]
 });
 if (!credential) return { authenticated: false };

 // Checkear que el nullifier no fue usado (prevenir replay)
 const nullifierUsed = await db.nullifiers.findOne({
 value: publicSignals[1]
 });
 if (nullifierUsed) return { authenticated: false, error: "proof_replayed" };

 // Almacenar el nullifier
 await db.nullifiers.insertOne({
 value: publicSignals[1],
 timestamp: Date.now(),
 credentialId: credential.id,
 });

 return { authenticated: true, userId: credential.userId };
}
```

## Ejemplo Práctico: Verificación de Edad Sin Revelar la Edad

Este es un caso de uso convincente. A continuación, un circuito que prueba "soy mayor de 18" sin revelar la fecha de nacimiento real:

```circom
pragma circom 2.1.0;

include "circomlib/circuits/poseidon.circom";
include "circomlib/circuits/comparators.circom";
include "circomlib/circuits/bitify.circom";

template AgeVerification() {
 signal input birthDate; // privado: timestamp Unix de nacimiento
 signal input identitySecret; // privado: secreto de identidad del usuario
 signal input salt; // privado: salt random

 signal input currentDate; // público: timestamp Unix actual
 signal input minAge; // público: edad mínima en segundos
 signal input identityHash; // público: Poseidon(identitySecret, salt)

 // Verificar propiedad de identidad
 component idHasher = Poseidon(2);
 idHasher.inputs[0] <== identitySecret;
 idHasher.inputs[1] <== salt;
 idHasher.out === identityHash;

 // Calcular edad = currentDate - birthDate
 signal age;
 age <== currentDate - birthDate;

 // Probar age >= minAge usando GreaterEqThan
 component ageCheck = GreaterEqThan(64);
 ageCheck.in[0] <== age;
 ageCheck.in[1] <== minAge;
 ageCheck.out === 1;

 // Asegurar que birthDate es positivo y razonable
 component birthPositive = GreaterThan(64);
 birthPositive.in[0] <== birthDate;
 birthPositive.in[1] <== 0;
 birthPositive.out === 1;
}

component main {public [currentDate, minAge, identityHash]} = AgeVerification();
```

El servidor setea ` currentDate ` y ` minAge ` (18 años en segundos = 567,648,000). El usuario prueba que satisface el requerimiento de edad sin revelar nunca cuándo nació. El servidor ni siquiera puede computar en qué año nació, todo lo que aprende es "esta persona controla la identidad y tiene suficiente edad."

## Consideraciones de Performance

A continuación, benchmarks reales de condiciones similares a producción:

```
Circuito: CredentialAuth (Poseidon hash + nullifier)
Constraints: ~640

Groth16:
 Trusted setup: ~2 segundos (una vez)
 Generación de prueba (browser WASM): 1.1 segundos
 Generación de prueba (Node.js nativo): 0.3 segundos
 Tamaño de prueba: 192 bytes
 Tiempo de verificación: 3ms

Plonk:
 Setup universal: ~10 segundos (una vez, reutilizable)
 Generación de prueba (browser WASM): 2.8 segundos
 Generación de prueba (Node.js nativo): 0.8 segundos
 Tamaño de prueba: 512 bytes
 Tiempo de verificación: 5ms
```

El número clave es el tiempo de generación de prueba en el browser. A 1-2 segundos, es usable pero no genial para UX. Mostrarías un spinner "Generando prueba segura...". Para apps nativas con generación de prueba acelerada por GPU, esto baja a menos de 500ms.

## Más Allá de Auth: ¿Qué Más Se puede Hacer?

Una vez que se tiene la infraestructura ZK lista, los mismos patrones se aplican a:

**Pruebas de propiedad de credenciales:** Probar que se tiene una credencial válida del emisor X sin revelar el contenido. Considerar en: probar que se es empleado verificado de la Empresa Y sin revelar tu ID de empleado, departamento, o salario.

**Membresía a conjuntos:** Probar que se está en una whitelist sin revelar cuál miembro sos. Útil para control de acceso donde se quiere unlinkability, el servidor sabe "un usuario autorizado accedió a este recurso" pero no puede decir cuál.

```circom
template MerkleProof(levels) {
 signal input leaf;
 signal input pathElements[levels];
 signal input pathIndices[levels];
 signal input root; // público: raíz Merkle de la whitelist

 component hashers[levels];
 signal intermediate[levels + 1];
 intermediate[0] <== leaf;

 for (var i = 0; i < levels; i++) {
 hashers[i] = Poseidon(2);
 hashers[i].inputs[0] <== intermediate[i] + (pathElements[i] - intermediate[i]) * pathIndices[i];
 hashers[i].inputs[1] <== pathElements[i] + (intermediate[i] - pathElements[i]) * pathIndices[i];
 intermediate[i + 1] <== hashers[i].out;
 }

 root === intermediate[levels];
}
```

**Pruebas de rango:** Probar que un valor está dentro de un rango sin revelar el valor. El score crediticio está arriba de 700. El saldo de cuenta está arriba de $1000.

## La Evaluación Honesta

ZK auth tiene limitaciones reales que la máquina de hype ignora:

1. **La gestión de claves es problema del usuario.** Si pierden su secreto, no hay flujo de "olvidé mi password". Necesitás un mecanismo de recuperación, que típicamente involucra un tercero de confianza (venciendo algunos beneficios de privacidad) o recuperación social (UX compleja).
2. **La generación de pruebas en dispositivos low-end es lenta.** Un teléfono Android de $100 puede tardar 5-8 segundos en generar una prueba Groth16. Eso mata la UX para apps mainstream.
3. **El desarrollo de circuitos es difícil.** Circom no es un lenguaje de propósito general. Debuggear sistemas de constraints es fundamentalmente diferente a debuggear código imperativo. Errores de off-by-one en lógica de constraints pueden crear bugs de soundness que permiten a atacantes falsificar pruebas.
4. **La cuestión de la ceremonia de setup.** Para Groth16, si el trusted setup está comprometido, un atacante puede falsificar pruebas. Las ceremonias de multi-party computation mitigan esto, pero agregan complejidad operacional.

A pesar de todo esto, la autenticación ZK probablemente se vuelva mainstream dentro de cinco años. No como reemplazo de OAuth en todos lados, sino como herramienta especializada para flujos sensibles a la privacidad: verificación de edad, checkeo de credenciales, votación anónima, y control de acceso donde la unlinkability importa.

La criptografía es sólida. El tooling está mejorando rápidamente. La pieza faltante es UX, y eso es un problema de ingeniería resoluble. Para quienes construyen sistemas que manejan datos sensibles de identidad, invertir en entender los fundamentos de ZK ahora vale la pena. Para cuando las librerías de ZK auth sean tan fáciles de usar como ` bcrypt `, tener ese conocimiento fundacional será una ventaja significativa.

## Preguntas frecuentes

### ¿Necesito una blockchain para usar zero-knowledge proofs para autenticación?

Absolutamente no. Las pruebas ZK son una primitiva matemática, funcionan perfectamente bien con un verificador server-side tradicional. El hype blockchain alrededor de ZK ha oscurecido el hecho de que ZK auth es útil para cualquier sistema donde quieras probar identidad sin revelar credenciales.

### ¿Cuánto tarda generar una prueba ZK en el dispositivo del usuario?

Para una prueba de credencial simple (preimagen de hash con Poseidon), aproximadamente 500ms-2s en un smartphone moderno usando provers compilados a WASM. Groth16 es más rápido para probar pero necesita parámetros por circuito descargados previamente. Plonk es más lento pero más flexible.

### ¿La autenticación ZK está lista para producción en 2026?

Para case es de uso específicos, sí. Verificación de edad, propiedad de credenciales y pruebas de membresía están listas para producción. Auth ZK de propósito general reemplazando OAuth todavía es experimental debido a desafíos de UX (tiempo de generación de prueba, gestión de claves).

## Tips

1. **Trade-off Groth16 vs Plonk:** Groth16 produce las pruebas más chicas (~192 bytes) y la verificación más rápida (~3ms), pero requiere un **trusted setup por circuito**. Plonk usa un trusted setup universal que funciona para cualquier circuito hasta un tamaño fijo, con pruebas levemente más grandes (~512 bytes) y ~5ms de verificación. Para auth en producción, utilizar Plonk a menos que el tamaño de prueba sea crítico (ej., verificación on-chain donde el gas importa).
2. **Asignación vs constraint de señales en Circom:** En Circom, ` <-- ` asigna un valor a una señal (computación del witness) mientras que ` === ` crea un constraint (verificación de prueba). Un bug común es usar ` <-- ` sin un ` === ` correspondiente, lo que significa que el prover puede asignar cualquier valor sin que el verifier lo detecte. Siempre utilizar ` <== ` (asignar + restringir) a menos que tengas una razón específica para separarlos.
3. **Poseidon sobre Pedersen para hashing ZK:** Utilizar el hash ` Poseidon ` de circomlib en vez de ` Pedersen ` para hashing de credenciales. Poseidon requiere ~300 constraints vs ~1,500 de Pedersen para un hash de 256 bits. Menos constraints = generación de prueba más rápida. En flujos de auth donde el usuario genera pruebas client-side, esto impacta directamente la latencia de UX.
4. **Patrón de nullifier para prevención de replay:** Toda prueba de ZK auth debería incluir un ` nullifier = Poseidon(secret, domain_separator) `. El verifier almacena nullifiers usados. Esto previene replay de pruebas sin revelar qué credencial se usó. Utilizar un ` domain_separator ` único por servicio para prevenir correlación de nullifiers entre servicios.

- zero-knowledge-proofs
- zk-snarks
- groth16
- plonk
- circom
- authentication
- cryptography

[Volver al Blog](<https://lucioduran.com/es/blog>)
