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:

  1. Tu cliente cifra el HTML con AES-GCM 256 usando una clave aleatoria que tú generas.
  2. Tu cliente envía el ciphertext a POST /api/store y recibe un id.
  3. 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í.
ZERO-KNOWLEDGE

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:

AVISO

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

POSThttps://share.yous.dev/api/store

Almacena un blob cifrado en KV con un TTL configurable.

Request body (JSON)

CampoTipoDescripción
ciphertextstringPayload binario codificado en base64 estándar. Máximo 5 MB ya codificados.
ttlnumberSegundos. Solo se aceptan 3600, 86400 o 604800. Por defecto 86400. Ignorado si burnAfterRead=true.
burnAfterReadbooleanSi 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

GEThttps://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ódigoEndpointCausa
400/api/store, /api/get/:idJSON inválido, ciphertext faltante, ID malformado.
404/api/get/:idEl blob no existe o ha expirado.
413/api/storeCiphertext > 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).

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:

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:

  1. Clona el repo y crea el namespace KV con wrangler kv:namespace create SHARE_KV.
  2. Pega el ID en wrangler.toml.
  3. npm run deploy.

El cliente (la encryption side) es la misma en cualquier instancia: el formato del payload es estable.

CTA

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".