Documentación de la API
easysecret.dev es una API REST para guardar y compartir secretos cifrados de extremo a extremo. El servidor almacena únicamente datos ya cifrados — nunca la clave de descifrado. Cada secreto se destruye en el momento de su primera lectura.
https://easysecret.dev
http://localhost:3001. Sustituye por la URL de producción antes de desplegar.
Autenticación
Los endpoints de secretos aceptan peticiones sin autenticar (plan Free: 10 req/día · 300/mes por IP)
o con una API key en la cabecera Authorization (planes con cuota mensual).
Authorization: Bearer es_live_aBcDeFgHiJkLmNoPqRsTuVwX
POST /api/signup. Si la pierdes, contacta con soporte
— no existe endpoint de recuperación.
Formato de la key
Las keys tienen el prefijo es_live_ seguido de 32 caracteres en base64url.
El servidor las almacena en claro en la base de datos local SQLite.
Planes y límites
El plan determina la cuota mensual de peticiones y el TTL máximo permitido para los secretos. Las peticiones sin API key se cuentan por IP con una ventana diaria.
| Plan | Autenticación | Peticiones | TTL máximo |
|---|---|---|---|
| Free (sin key) | Por IP | 10/día · 300/mes | 24 h |
| Basic (con key) | Bearer token | 3.000 / mes | 24 h |
| Pro (con key) | Bearer token | 50.000 / mes | 30 días |
Rate Limiting
El endpoint de signup tiene un límite propio por IP para frenar altas masivas. El resto de endpoints usan los límites del plan descrito arriba.
| Ruta | Límite | Ventana |
|---|---|---|
POST /api/signup |
5 peticiones | 1 hora por IP |
| Endpoints de secretos (sin key, diario) | 10 peticiones | 24 h por IP |
| Endpoints de secretos (sin key, mensual) | 300 peticiones | 30 días por IP |
| Endpoints de secretos (con key) | Según plan | 30 días por key |
Al superar el límite el servidor devuelve 429
con el mensaje de error correspondiente.
Errores
Todos los errores devuelven JSON con el campo error y el código HTTP correspondiente.
{ "error": "descripción del error" }
| 400 | Cuerpo JSON inválido o campos obligatorios ausentes. |
| 401 | API key ausente o inválida. |
| 404 | Secreto no existe o ya fue leído/caducó. |
| 409 | Email ya registrado. |
| 429 | Rate limit superado (Free, cuota mensual o límite de signup). |
| 5xx | Error del servidor. Reintenta con backoff exponencial. |
Crea una cuenta nueva y devuelve la API key. No requiere autenticación. La key se muestra una única vez en la respuesta — guárdala antes de cerrar.
Cuerpo de la petición
| Campo | Tipo | Descripción | |
|---|---|---|---|
| string | requerido | Dirección de correo. Debe ser única en el sistema. |
Ejemplo
curl -X POST https://easysecret.dev/api/signup \ -H "Content-Type: application/json" \ -d '{"email":"tu@ejemplo.com"}'
{
"email": "tu@ejemplo.com",
"api_key": "es_live_aBcDeFgHiJkLmNoPqRsTuVwX", // ⚠ solo aquí
"plan": "basic",
"message": "Guarda tu api_key, no se mostrará de nuevo."
}
Respuestas de error
| 400 | Email inválido o ausente. |
| 409 | Ya existe una cuenta con ese email. |
| 429 | 5 intentos de signup en la última hora desde esta IP. |
Almacena un secreto ya cifrado en Redis con un TTL. El servidor guarda el texto cifrado
y el IV — nunca la clave de descifrado, que el cliente pasa solo en el fragmento
de URL (#).
Autenticación
Opcional. Sin Authorization el límite del plan Free es 10 peticiones/día · 300/mes por IP.
Con API key se usa la cuota del plan.
Cuerpo de la petición
| Campo | Tipo | Descripción | |
|---|---|---|---|
| ivBase64 | string | requerido | Vector de inicialización AES-GCM codificado en base64 (12 bytes → 16 chars). |
| cipherBase64 | string | requerido | Texto cifrado codificado en base64. El servidor lo almacena opaco. |
| ttl | number | opcional | Segundos de vida. Máximo: 86400 (free) / 2592000 (pro). Por defecto 86400. |
Ejemplo — cifrar en el navegador y guardar
// 1. Generar clave e IV aleatorios const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"] ); // 2. Cifrar const encoded = new TextEncoder().encode("mi secreto"); const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded); // 3. Exportar clave (para el fragmento #) y IV (para la API) const rawKey = await crypto.subtle.exportKey("raw", key); const ivBase64 = btoa(String.fromCharCode(...iv)); const cipherB64 = btoa(String.fromCharCode(...new Uint8Array(cipher))); const keyBase64 = btoa(String.fromCharCode(...new Uint8Array(rawKey))) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); // 4. Guardar en la API const res = await fetch("/api/secret", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ivBase64, cipherBase64: cipherB64 }) }); const { id } = await res.json(); // 5. Construir enlace — la clave NUNCA va al servidor const link = `https://easysecret.dev/ver/${id}#${keyBase64}`;
curl -X POST https://easysecret.dev/api/secret \ -H "Authorization: Bearer es_live_aBcDeFgHiJkLmNoPqRsTuVwX" \ -H "Content-Type: application/json" \ -d '{"ivBase64":"aXZfdGVzdA==","cipherBase64":"Y2lwaGVy","ttl":3600}'
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"expires_in_seconds": 3600
}
Respuestas de error
| 400 | Faltan ivBase64 o cipherBase64. |
| 401 | API key inválida. |
| 429 | Límite de peticiones superado. |
Lee y destruye atómicamente el secreto. Tras esta llamada el secreto desaparece
de Redis para siempre — no se puede volver a leer. El descifrado ocurre en el cliente
usando la clave que va en el fragmento # de la URL.
Parámetros de ruta
| Parámetro | Tipo | Descripción | |
|---|---|---|---|
| id | string (UUID) | requerido | UUID del secreto, devuelto por POST /api/secret. |
Ejemplo — leer y descifrar en el navegador
curl https://easysecret.dev/api/secret/550e8400-e29b-41d4-a716-446655440000
{
"ivBase64": "aXZfdGVzdA==",
"cipherBase64": "Y2lwaGVy..."
}
// La clave viene del fragmento # de la URL (nunca del servidor) const keyB64UrlSafe = window.location.hash.substring(1); let keyB64 = keyB64UrlSafe.replace(/-/g, '+').replace(/_/g, '/'); while (keyB64.length % 4) keyB64 += '='; const base64ToBuf = b64 => Uint8Array.from(atob(b64), c => c.charCodeAt(0)).buffer; const key = await crypto.subtle.importKey( "raw", base64ToBuf(keyB64), { name: "AES-GCM" }, false, ["decrypt"] ); const res = await fetch(`/api/secret/${secretId}`); const data = await res.json(); const decrypted = await crypto.subtle.decrypt( { name: "AES-GCM", iv: base64ToBuf(data.ivBase64) }, key, base64ToBuf(data.cipherBase64) ); const plainText = new TextDecoder().decode(decrypted);
Respuestas de error
| 404 | El secreto no existe, ya fue leído o caducó su TTL. |
Zero-Knowledge
El diseño de easysecret garantiza que el servidor nunca puede acceder al contenido de un secreto, incluso con acceso completo a la base de datos y a Redis.
1. El cliente genera una clave AES-GCM aleatoria en el navegador.
2. Cifra el texto con esa clave. Solo el texto cifrado y el IV van al servidor.
3. La clave se codifica en base64url y se añade al fragmento de URL (
#).4. El fragmento nunca se envía al servidor en ninguna petición HTTP.
5. El destinatario abre la URL completa: su navegador descifra localmente con la clave del fragmento.
Esto significa que incluso si el servidor fuera comprometido, el atacante solo obtendrá texto cifrado sin la clave — ilegible sin el fragmento de URL.
TTL y caducidad
Cada secreto tiene un tiempo de vida (TTL) configurable. Transcurrido ese tiempo Redis expira la entrada automáticamente y el secreto desaparece sin necesidad de limpiezas manuales.
| Plan | TTL máximo | TTL por defecto |
|---|---|---|
| Free / Basic | 86.400 s (24 h) | 86.400 s |
| Pro | 2.592.000 s (30 días) | 86.400 s |
Si pasas un ttl mayor al máximo del plan, el servidor lo trunca silenciosamente
al máximo permitido. El campo expires_in_seconds de la respuesta indica
el TTL que se aplicó realmente.
Destrucción atómica
La lectura del secreto usa la operación atómica GETDEL de Redis,
que lee y borra en una sola instrucción. Esto garantiza que no hay ventana de tiempo
entre la lectura y la eliminación — incluso bajo carga alta.
404. No es posible que ambos lo lean.
Una vez que GET /api/secret/:id devuelve 200, el secreto ya no existe
en Redis. Reintentar la petición devolverá 404.