API & guía de integración
share.yous.dev expone una API HTTP minimalista que permite a cualquier herramienta publicar HTML cifrado y obtener un enlace privado. El Worker solo guarda bytes opacos: el cifrado debe ocurrir en tu cliente para mantener la propiedad zero-knowledge.
Introducción
Hay tres operaciones a entender:
- Tu cliente cifra el HTML con AES-GCM 256 usando una clave aleatoria que tú generas.
- Tu cliente envía el ciphertext a
POST /api/storey recibe unid. - Tu cliente construye la URL final colocando la clave en el hash:
https://share.yous.dev/v/<id>#k=<clave>. El navegador del receptor descifra ahí.
El servidor nunca debe ver la clave. Por diseño del protocolo HTTP, el fragmento (#) de la URL no se envía
al servidor en una request. Si tu integración pone la clave en el path o en un parámetro, rompes la propiedad.
Filosofía zero-knowledge
Mantenemos cuatro reglas duras:
- El servidor jamás recibe la clave de descifrado.
- El servidor solo almacena bytes que ya están cifrados al llegar.
- No hay autenticación ni cuentas de usuario; no podemos correlacionar accesos.
- El visor renderiza el HTML descifrado en un
<iframe sandbox="allow-scripts">sinallow-same-origin, así el contenido vive en origen nulo.
Toda integración que use la API tiene que cifrar antes de subir. Si subes texto en claro confiando en que "es privado", estás falsificando la propiedad y engañando a tus usuarios.
Quickstart
El flujo mínimo en pseudocódigo:
pseudo1. rawKey = random_bytes(32)
2. iv = random_bytes(12)
3. payload = AES_GCM_encrypt(key=rawKey, iv=iv, plaintext=html)
4. blob = [version=1][hasPwd=0][iv][payload] // ver §Formato del payload
5. ciphertext_b64 = base64(blob)
6. id = POST /api/store { ciphertext: ciphertext_b64, ttl: 86400 }
7. url = https://share.yous.dev/v/{id}#k={base64url(rawKey)}
POST /api/store
https://share.yous.dev/api/storeAlmacena un blob cifrado en KV con un TTL configurable.
Request body (JSON)
| Campo | Tipo | Descripción |
|---|---|---|
ciphertext | string | Payload binario codificado en base64 estándar. Máximo 5 MB ya codificados. |
ttl | number | Segundos. Solo se aceptan 3600, 86400 o 604800. Por defecto 86400. Ignorado si burnAfterRead=true. |
burnAfterRead | boolean | Si true, el blob se elimina al primer GET. TTL de seguridad: 7 días. |
Respuesta (200)
json{
"id": "aZ8c3K1q",
"url": "/v/aZ8c3K1q",
"expiresIn": 86400,
"burnAfterRead": false
}
GET /api/get/:id
https://share.yous.dev/api/get/<id>
Devuelve el blob cifrado. Si el registro fue creado con burnAfterRead=true, se elimina en el mismo
momento en que el servidor responde — tu cliente debe estar preparado para que un segundo GET devuelva 404.
Respuesta (200)
json{
"ciphertext": "<base64>",
"burnAfterRead": false,
"createdAt": 1714994000000
}
Códigos de error
| Código | Endpoint | Causa |
|---|---|---|
400 | /api/store, /api/get/:id | JSON inválido, ciphertext faltante, ID malformado. |
404 | /api/get/:id | El blob no existe o ha expirado. |
413 | /api/store | Ciphertext > 5 MB. |
CORS y límites
Todas las rutas /api/* envían Access-Control-Allow-Origin: *. Puedes llamarlas
desde cualquier origen (extensiones, apps locales, sitios de terceros).
- Tamaño máximo del ciphertext: 5 MB (ya codificado en base64).
- TTL permitidos:
3600,86400,604800. - El ID de un blob es base64url de 9 bytes aleatorios (≈12 caracteres).
- No hay rate limit duro, pero abusar disparará protecciones de Cloudflare.
Formato del payload
El campo ciphertext que envías a la API es la concatenación binaria de un cabecero versionado
y el ciphertext de AES-GCM, codificado en base64 estándar:
layoutbyte 0 : version (uint8, actualmente 1)
byte 1 : hasPassword (uint8, 0 ó 1)
bytes 2..17 : salt PBKDF2 (16 bytes; solo si hasPassword == 1)
bytes ..+12 : IV de AES-GCM (12 bytes)
bytes ..end : ciphertext + tag (AES-GCM con tag de 16 bytes incluido)
Reglas:
- El tag de AES-GCM va al final del ciphertext (es lo que devuelve
crypto.subtle.encrypt). No lo separes. - El IV debe ser de 12 bytes, único por mensaje. Reusarlo con la misma clave compromete la confidencialidad.
- La versión permite añadir variantes futuras (curve, KDF) sin romper consumidores antiguos.
Formato de la URL final
urlhttps://share.yous.dev/v/<id>#k=<rawKey-base64url>
https://share.yous.dev/v/<id>#k=<rawKey-base64url>&p=1
k es la clave aleatoria de 32 bytes, codificada en base64url sin padding.
p=1 es solo una pista de UX para que el visor pinte el prompt de contraseña antes
de intentar descifrar; la fuente de verdad sigue siendo el byte hasPassword dentro del payload.
Capa opcional de contraseña
Si quieres exigir una contraseña además del enlace, deriva una clave con PBKDF2-SHA256
(250.000 iteraciones, salt aleatorio de 16 bytes) y combínala por XOR con la clave aleatoria del enlace.
Almacena el salt dentro del payload (bytes 2..17). Sigue haciendo falta la clave del # y la contraseña para descifrar.
jsconst salt = crypto.getRandomValues(new Uint8Array(16));
const pwKey = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']
);
const derived = new Uint8Array(await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt, iterations: 250000, hash: 'SHA-256' }, pwKey, 256
));
const actualKey = new Uint8Array(32);
for (let i = 0; i < 32; i++) actualKey[i] = rawKey[i] ^ derived[i];
// usa actualKey para AES-GCM. Sigue compartiendo rawKey en el #.
Ejemplos completos
Navegador (Web Crypto API)
Funciona en cualquier página servida por HTTPS. No requiere dependencias.
jsasync function shareHtml(html, { ttl = 86400, burnAfterRead = false, password } = {}) {
const enc = new TextEncoder();
const data = enc.encode(html);
const rawKey = crypto.getRandomValues(new Uint8Array(32));
let actualKey = rawKey, salt = null;
if (password) {
salt = crypto.getRandomValues(new Uint8Array(16));
const pwKey = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']);
const derived = new Uint8Array(await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt, iterations: 250000, hash: 'SHA-256' }, pwKey, 256
));
actualKey = new Uint8Array(32);
for (let i = 0; i < 32; i++) actualKey[i] = rawKey[i] ^ derived[i];
}
const cryptoKey = await crypto.subtle.importKey('raw', actualKey, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, data));
const headerLen = 2 + (password ? 16 : 0) + 12;
const blob = new Uint8Array(headerLen + ct.length);
let p = 0;
blob[p++] = 1;
blob[p++] = password ? 1 : 0;
if (password) { blob.set(salt, p); p += 16; }
blob.set(iv, p); p += 12;
blob.set(ct, p);
const b64 = btoa(String.fromCharCode(...blob));
const b64url = btoa(String.fromCharCode(...rawKey))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const body = burnAfterRead ? { ciphertext: b64, burnAfterRead: true } : { ciphertext: b64, ttl };
const r = await fetch('https://share.yous.dev/api/store', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error('store failed: ' + r.status);
const { id } = await r.json();
const fragment = password ? `#k=${b64url}&p=1` : `#k=${b64url}`;
return `https://share.yous.dev/v/${id}${fragment}`;
}
Node.js (≥ 19, sin dependencias)
Node 19+ trae crypto.webcrypto y fetch globales, así que el código es prácticamente idéntico al del navegador.
jsimport { webcrypto as crypto } from 'node:crypto';
const ENDPOINT = 'https://share.yous.dev';
export async function shareHtml(html, { ttl = 86400, burnAfterRead = false, password } = {}) {
const enc = new TextEncoder();
const data = enc.encode(html);
const rawKey = crypto.getRandomValues(new Uint8Array(32));
let actualKey = rawKey, salt = null;
if (password) {
salt = crypto.getRandomValues(new Uint8Array(16));
const pwKey = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']);
const derived = new Uint8Array(await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt, iterations: 250000, hash: 'SHA-256' }, pwKey, 256
));
actualKey = new Uint8Array(32);
for (let i = 0; i < 32; i++) actualKey[i] = rawKey[i] ^ derived[i];
}
const cryptoKey = await crypto.subtle.importKey('raw', actualKey, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, data));
const headerLen = 2 + (password ? 16 : 0) + 12;
const blob = new Uint8Array(headerLen + ct.length);
let p = 0;
blob[p++] = 1;
blob[p++] = password ? 1 : 0;
if (password) { blob.set(salt, p); p += 16; }
blob.set(iv, p); p += 12;
blob.set(ct, p);
const b64 = Buffer.from(blob).toString('base64');
const b64url = Buffer.from(rawKey).toString('base64url');
const body = burnAfterRead ? { ciphertext: b64, burnAfterRead: true } : { ciphertext: b64, ttl };
const r = await fetch(`${ENDPOINT}/api/store`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`store failed: ${r.status}`);
const { id } = await r.json();
const fragment = password ? `#k=${b64url}&p=1` : `#k=${b64url}`;
return `${ENDPOINT}/v/${id}${fragment}`;
}
Python 3 (cryptography + httpx)
Requiere pip install cryptography httpx.
pythonimport os, base64, httpx
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
ENDPOINT = "https://share.yous.dev"
def share_html(html: str, ttl: int = 86400, burn_after_read: bool = False, password: str | None = None) -> str:
raw_key = os.urandom(32)
iv = os.urandom(12)
salt = os.urandom(16) if password else None
if password:
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=250_000)
derived = kdf.derive(password.encode("utf-8"))
actual_key = bytes(a ^ b for a, b in zip(raw_key, derived))
else:
actual_key = raw_key
aesgcm = AESGCM(actual_key)
ct = aesgcm.encrypt(iv, html.encode("utf-8"), None) # ct includes tag
header = bytes([1, 1 if password else 0])
blob = header + (salt or b"") + iv + ct
b64 = base64.b64encode(blob).decode("ascii")
b64url = base64.urlsafe_b64encode(raw_key).rstrip(b"=").decode("ascii")
body = {"ciphertext": b64}
if burn_after_read: body["burnAfterRead"] = True
else: body["ttl"] = ttl
r = httpx.post(f"{ENDPOINT}/api/store", json=body, timeout=10)
r.raise_for_status()
blob_id = r.json()["id"]
fragment = f"#k={b64url}&p=1" if password else f"#k={b64url}"
return f"{ENDPOINT}/v/{blob_id}{fragment}"
if __name__ == "__main__":
print(share_html("<h1>Hola</h1>", ttl=3600))
curl (solo subida)
curl no puede generar la clave AES ni cifrar AES-GCM por sí solo. Lo que sí puede es subir un blob ya cifrado.
Útil para cron jobs que pre-cifran con openssl o un script.
bash# Asume que has cifrado el HTML y construido el blob según §Formato del payload,
# y lo tienes en base64 dentro de la variable $CIPHERTEXT.
curl -sS -X POST https://share.yous.dev/api/store \
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg ct "$CIPHERTEXT" '{ciphertext:$ct, ttl:86400}')"
# Respuesta:
# {"id":"aZ8c3K1q","url":"/v/aZ8c3K1q","expiresIn":86400,"burnAfterRead":false}
# La URL para tu receptor (la clave la concatenas tú, base64url del raw_key):
# https://share.yous.dev/v/aZ8c3K1q#k=<rawKey-base64url>
Self-hosting
El Worker es de un solo archivo y vive en GitHub. Si prefieres operarlo en tu propia cuenta de Cloudflare:
- Clona el repo y crea el namespace KV con
wrangler kv:namespace create SHARE_KV. - Pega el ID en
wrangler.toml. npm run deploy.
El cliente (la encryption side) es la misma en cualquier instancia: el formato del payload es estable.
Si tu integración es interesante (CLI, plugin de IDE, bot, extensión), abre un PR en el README para añadirla a la lista de "Built with share.yous.dev".