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.
// 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:
# 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):
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:
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:
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.
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:
-
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).
-
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.
-
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.
-
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.