Guía para desarrolladores
Todo lo que tu backend tiene que implementar para hablar con SecureChat desde un lenguaje distinto de Node o Python, con vectores de prueba y ejemplos probados en PHP y Go.
Traducción al inglés: 13 · Integrating from other languages.
Para empresas cuyo backend no es Node ni Python. Aquí está todo lo que tu backend tiene que implementar para hablar con SecureChat, sacado del código y no de la intención:
| Fuente | Qué define |
|---|---|
envelope.js | El sobre v1 sc1, envolturas, adjuntos, huellas, firmas Ed25519. Fuente única de verdad |
v1.json | 82 vectores de prueba compartidos por la app y los SDK |
SDK de Node (securechat-sdk) | Flujo de la empresa, formato de los archivos de llave |
| La implementación del servidor | Cómo el backend verifica tus peticiones y firma sus webhooks (§3) |
| Referencia de la API (disponible en el portal de empresas) | Endpoints /v1 y eventos; esta guía incluye los que necesitas |
Si tu implementación pasa los 82 vectores de v1.json (§8), es compatible con la app. Hay
ejemplos probados en PHP y Go en §11.
Lo mínimo que tienes que construir:
- Leer tus llaves (
private.key/public.key, ysigning.keysi rotas) — §2 - Verificar la firma HMAC de cada webhook y firmar cada petición a
/v1— §3 - Abrir la clave de conversación sellada para ti y verificar la vinculación de la invitación — §4
- Descifrar los mensajes del usuario y cifrar tus respuestas — §5
- (Opcional) Adjuntos — §6 · Rotación de llave — §7
Todo usa primitivas de libsodium: X25519 (crypto_box_seal),
XChaCha20-Poly1305 IETF, BLAKE2b y Ed25519, más HMAC-SHA256 para el transporte. No hay nada
propietario: solo hay que juntar las piezas en el orden exacto.
1. Convenciones que aplican a todo
- base64url sin relleno (RFC 4648 §5, alfabeto
A–Z a–z 0–9 - _, sin=) para todo el material criptográfico. Es la varianteURLSAFE_NO_PADDINGde libsodium. El decodificador debe ser estricto: rechaza=, espacios, saltos de línea, caracteres de otro alfabeto, longitud% 4 == 1y bits sobrantes en el último carácter. Error →INVALID_INPUT - Ids (
conversationId,clientMessageId,companyId):^[A-Za-z0-9_-]{1,128}$. Así el separador|del AD no puede aparecer dentro de un campo. Cuidado con las regex: en PHP usa el modificadorD(o\z); sin él,$acepta un\nfinal - Texto: UTF-8 estricto (sin overlongs ni surrogates). Al descifrar, un UTF-8 inválido es
INVALID_INPUT, nunca texto con caracteres de reemplazo - Números en JSON:
v,keyVersion,size,chunkSizeson enteros. La plataforma nunca los emite como1.0 - Errores: cuando algo no cuadra, se lanza un error con código y jamás se devuelve el ciphertext ni una cadena vacía como si fuera texto. Los códigos (usados por los vectores):
| Código | Significa |
|---|---|
INVALID_INPUT | Formato inválido: base64url, longitud de llave/nonce, id fuera de [A-Za-z0-9_-], UTF-8 |
UNSUPPORTED_VERSION | v ≠ 1 o scheme ≠ "sc1" (o meta de adjunto de otra versión) |
DECRYPT_FAILED | La autenticación falló: llave equivocada, manipulación, longitud imposible |
KEY_MISMATCH | La envoltura abre con tu llave, pero es de otra conversación o versión |
BAD_PADDING | Descifra bien pero el relleno no es ISO 7816-4 válido |
SIGNATURE_INVALID | Firma Ed25519 que no verifica |
BINDING_INVALID | La llave del usuario no está ligada al secreto de la invitación |
Las constantes de dominio: DOMAIN = "SecureChat|v1". Todas las cadenas que se firman, se
hashean o van como AD empiezan por ahí.
2. Archivos de llave
npx securechat-sdk keygen --out ./keys (o el equivalente de tu lenguaje, §2.3) escribe cuatro
archivos. Cada uno es una línea con base64url sin relleno y un \n final. Al leerlos, haz
trim() del texto antes de decodificar.
| Archivo | Contenido | Bytes | Permisos | Va al portal |
|---|---|---|---|---|
private.key | Secreta X25519 (crypto_box_keypair) | 32 | 0600 | Nunca |
public.key | Pública X25519 | 32 | 0644 | Sí: Llaves y API |
signing.key | Secreta Ed25519 (crypto_sign_keypair) en formato libsodium: semilla(32) ‖ pública(32) | 64 | 0600 | Nunca |
signing.pub | Pública Ed25519 | 32 | 0644 | Sí, junto a la anterior |
- La secreta X25519 son los 32 bytes crudos de libsodium. La pública debe ser
X25519(secreta, 9)(crypto_scalarmult_base); compruébalo al cargar - Hay librerías que esperan solo la semilla Ed25519 (32 bytes): son los primeros 32 bytes
de
signing.key. Go (ed25519.PrivateKey) y libsodium usan los 64 - Tras
securechat rotate, la llave anterior queda comoprivate.v<N>.key/public.v<N>.key. Consérvalas: las conversaciones existentes siguen selladas para ellas (§7)
2.1 Huella
fp = BLAKE2b-256( UTF8("SecureChat|v1|fp|") ‖ publicKey ) // crypto_generichash(32, …, sin clave)
- En el link de invitación va en base64url (
&fp=…, §4.3) - Para mostrar a una persona: hex en minúsculas en 16 grupos de 4 separados por espacio
(
7c8b a68b 9c68 …), lo que imprimekeygenysecurechat fingerprint public.key
2.2 Credenciales del portal
| Credencial | Formato | Uso |
|---|---|---|
| API key | sk_live_<base64url de 32 bytes> | Authorization: Bearer … en /v1. Se muestra una sola vez |
webhookSecret | whsec_<base64url de 32 bytes> | Clave HMAC en los dos sentidos (§3) |
2.3 Generarlas sin Node
Es libsodium normal: crypto_box_keypair() y crypto_sign_keypair(), codificar en base64url
sin relleno, una línea por archivo. El ejemplo PHP trae Keys::generateKeyFiles($dir) con los
mismos nombres y permisos.
3. Firmas HMAC: peticiones a /v1 y webhooks
Las dos direcciones usan HMAC-SHA256, la misma cabecera y el mismo secreto:
X-SecureChat-Signature: t=<unix en segundos>,v1=<hex en minúsculas de 64 caracteres>
La clave HMAC es el webhookSecret completo como texto UTF-8, prefijo whsec_ incluido.
No lo decodifiques de base64.
3.1 Firmar tus peticiones a /v1
canonical = t + "." + METHOD + "." + pathAndQuery + "." + rawBody
v1 = hex( HMAC-SHA256( key = webhookSecret, msg = UTF8(canonical) ) )
| Pieza | Regla |
|---|---|
t | floor(now/1000) en segundos. El backend acepta |ahora − t| ≤ 300 |
METHOD | En mayúsculas: GET, POST |
pathAndQuery | Exactamente lo que va en la URL tras el host: /v1/conversations?status=active&limit=20. Sin dominio ni stage. Firma la misma cadena que envías (si codificas la query, firma la versión codificada) |
rawBody | Los bytes exactos del cuerpo. Sin cuerpo (GET) → cadena vacía, y el canónico termina en . |
Cabeceras de cada petición (como el SDK de Node):
Authorization: Bearer sk_live_…
X-SecureChat-Signature: t=…,v1=…
Content-Type: application/json ← solo si hay cuerpo
Idempotency-Key: <único> ← en todo lo que no es GET
Idempotency-Key: hasta 128 caracteres ASCII imprimibles (0x21–0x7e). Se guarda 24 h por empresa: misma clave y misma petición → la misma respuesta (Idempotent-Replayed: true); misma clave con otra petición →409 IDEMPOTENCY_CONFLICT. Reutiliza la clave al reintentar la misma operación (p. ej.reply-<eventId>)- Los POST sin datos (
close,typing,retry) se mandan con cuerpo{}, como el SDK - Errores de firma:
401 INVALID_SIGNATUREo401 SIGNATURE_EXPIRED(revisa tu reloj: NTP) - Tras rotar el secreto en el portal, el anterior sigue valiendo 24 h para tus peticiones; los webhooks se firman ya con el nuevo
Ejemplo con el secreto de prueba whsec_TEST-ONLY_0123456789abcdefghijklmnopqrstuvwxyzAB
(de signing.json):
canonical: 1759088531.GET./v1/conversations/cnv_01JG7X8M2K9P3Q4R5S6T7U8V9W.
cabecera: t=1759088531,v1=45778239dec9b466d956d785c3d0874293e7dce42a3d0ad5e5696aebff2336fd
canonical: 1759088531.POST./v1/conversations/cnv_01JG7X8M2K9P3Q4R5S6T7U8V9W/close.{}
cabecera: t=1759088531,v1=32fb08d3f102a916ade197cfb3f57639cd30013f78951d4a369b7c7d1b3f52f4
3.2 Verificar los webhooks que recibes
SecureChat hace POST a tu URL (HTTPS) con:
Content-Type: application/json
User-Agent: SecureChat-Webhooks/1.0
X-SecureChat-Signature: t=…,v1=…
X-SecureChat-Event-Id: evt_…
X-SecureChat-Delivery-Id: dlv_…
X-SecureChat-Attempt: 1
canonical = t + "." + rawBody
Pasos, en este orden (los mismos que sigue el SDK de Node):
- Cuerpo crudo: lee los bytes tal como llegaron (
php://input,io.ReadAll(r.Body),request.getInputStream()…). Si tu framework ya parseó el JSON y lo vuelves a serializar, los bytes cambian y la firma no cuadra. Es el error de integración más común - Parsear la cabecera: separa por
,; cada parte por el primer=; recorta espacios de clave y valor. Necesitast(entero) yv1(exactamente^[0-9a-f]{64}$, minúsculas). Falta la cabecera →SIGNATURE_MISSING; formato inválido →SIGNATURE_MALFORMED - Ventana: rechaza si
|ahora − t| > 300s (SIGNATURE_EXPIRED). Evita replays - Recalcular
hex(HMAC-SHA256(webhookSecret, t + "." + rawBody)) - Comparar en tiempo constante (
hash_equals,hmac.Equal,MessageDigest.isEqual,CryptographicOperations.FixedTimeEquals,Rack::Utils.secure_compare). Distinto →SIGNATURE_INVALIDy responde401 - Idempotencia por
eventId: la entrega es "al menos una vez". Si ya lo procesaste, responde200y no hagas nada - Responde 2xx en menos de 10 s y procesa en una cola. 400/401/403/422 no se reintentan; 408, 429, 5xx y timeouts sí (10 s, 1 min, 5 min, 30 min, 2 h)
Tolera campos y tipos de evento nuevos. El catálogo está en la referencia de la API (disponible en el portal de empresas).
4. La clave de conversación
Cada conversación tiene una clave simétrica K de 32 bytes que genera la app al canjear
la invitación. La app la sella dos veces: para tu llave pública (wrapForCompany) y para la
suya (wrapForUser). SecureChat solo transporta las envolturas; no puede abrirlas.
4.1 Formato de la envoltura
{ "keyVersion": 1, "sealed": "base64url…" }
payload = "SCK1" ‖ u32be(keyVersion) ‖ K(32) ‖ UTF8(conversationId)
sealed = crypto_box_seal(payload, recipientPublicKey) // X25519 + XSalsa20-Poly1305 anónimo
crypto_box_seal es el sealed box estándar de libsodium: pk_efímera(32) ‖ crypto_box(…)
con nonce BLAKE2b-192(pk_efímera ‖ pk_destinatario). Añade 48 bytes. Usa la función de tu
librería; no lo reconstruyas a mano salvo que no haya otra opción.
4.2 Abrirla (openConversationKey)
Recibes la envoltura en data.conversationKey de cada message.created y de
conversation.started, y en wrapForCompany de GET /v1/conversations/{id}.
1. conversationId cumple el regex de ids; keyVersion entero 1..2^31-1 → si no, INVALID_INPUT
2. sealed = b64uDecode(wrapped.sealed) → INVALID_INPUT
3. payload = crypto_box_seal_open(sealed, tuPública, tuSecreta) → falla: DECRYPT_FAILED
4. len(payload) ≥ 41 y payload[0..4) == "SCK1" → si no, KEY_MISMATCH
5. u32be(payload[4..8)) == wrapped.keyVersion → si no, KEY_MISMATCH
6. cid = payload[40..] (UTF-8 válido) y cid == conversationId esperado → si no, KEY_MISMATCH
7. K = payload[8..40)
El conversationId del paso 6 es el del evento (o el que pediste), no uno que saques del
payload. Así nadie puede mover una envoltura a otra conversación.
Varias llaves (tras rotar): prueba tu lista [actual, anterior, …] en orden y pasa a la
siguiente solo si el error es DECRYPT_FAILED. Un KEY_MISMATCH significa que la llave
correcta abrió algo con contexto equivocado: probar otra no lo arregla.
Puedes cachear K por conversationId (el SDK guarda hasta 1.000 en memoria). Cada evento
message.created trae la envoltura, así que un receptor sin estado también funciona.
4.3 Invitación con secreto y vinculación
Al crear la invitación (POST /v1/invites → inviteUrl), genera un inviteSecret de
32 bytes aleatorios, guárdalo asociado al conversationId de la respuesta y entrega al
cliente:
<inviteUrl>#s=<b64u(inviteSecret)>&fp=<b64u(huella de tu public.key actual)>
El fragmento (#…) no viaja al servidor en HTTP. La app usa fp para comprobar tu llave y
s para calcular la vinculación, que llega en conversation.started como data.inviteBinding:
msg = "SecureChat|v1|invite-binding|" + conversationId + "|" + b64u(userPublicKey)
+ "|" + wrapForCompany.keyVersion + "|" + wrapForCompany.sealed
tag = b64u( BLAKE2b-256( key = inviteSecret(32 bytes), msg = UTF8(msg) ) ) // crypto_generichash con clave
userPublicKeysale dedata.userPublicKey(decodifícalo y vuelve a codificarlo, o usa la cadena tal cual si ya validaste que es base64url de 32 bytes);wrapForCompanyesdata.conversationKey, consealedtal cual viene- Compara
b64uDecode(data.inviteBinding)con tu tag en tiempo constante. Distinto →BINDING_INVALID: alguien sustituyó la llave del usuario. No confíes en la conversación - Sin
inviteBinding(el cliente tecleó el código corto) no hay nada que verificar: la conversación queda no verificada
4.4 Reinvitación y rewrap (cambio de teléfono)
POST /v1/conversations/{id}/reinvite crea una invitación nueva para la misma conversación
(genera otro inviteSecret y arma el link igual que en §4.3). Cuando el cliente la canjea:
- llega
conversation.startedcondata.isReinvite: true, eluserPublicKeynuevo y enconversationKeyla envoltura existente para ti - la conversación queda en
awaiting_key(la app no deja escribir) - tú abres K con tu llave (§4.2), la sellas para la llave nueva y la subes:
wrapForUser = { keyVersion: data.conversationKey.keyVersion,
sealed: b64u(crypto_box_seal("SCK1" ‖ u32be(kv) ‖ K ‖ conversationId, userPublicKey)) }
POST /v1/conversations/{id}/rewrap { "wrapForUser": { "keyVersion": 1, "sealed": "…" } }
El rewrap entrega todo el historial a esa llave. Hazlo solo si la vinculación verificó.
Sin vinculación, confirma con el cliente por otro canal antes de resellar (el SDK devuelve
reason: "REWRAP_REQUIRES_BINDING" y no resella).
4.5 Dos "versiones de llave" distintas
| Nombre | Qué versiona | Dónde aparece |
|---|---|---|
keyVersion de la conversación | La clave K | Envolturas, sobre de mensaje, AD, conversation.keyVersion. Hoy siempre 1 |
keyVersion de la empresa | Tu llave X25519 | Portal, firma de plataforma, rotación (§7) |
Tus respuestas llevan el keyVersion de la envoltura que abriste
(data.conversationKey.keyVersion o conversation.keyVersion). Otro valor →
409 KEY_VERSION_MISMATCH.
5. Mensajes: sobre v1 sc1
5.1 Campos
{
"v": 1,
"scheme": "sc1",
"keyVersion": 1,
"clientMessageId": "cli_01JG…",
"nonce": "base64url de 24 bytes",
"ciphertext": "base64url"
}
Llega en data.encryption de message.created y en encryption de cada mensaje de
GET /v1/conversations/{id}/messages. Es el mismo objeto que mandas en POST /v1/messages.
5.2 Construcción
AD = "SecureChat|v1|msg|" + conversationId + "|" + senderType + "|" + clientMessageId + "|" + keyVersion
padded = UTF8(texto) ‖ 0x80 ‖ 0x00… hasta el siguiente múltiplo de 256 (ISO/IEC 7816-4)
nonce = 24 bytes aleatorios
ciphertext = crypto_aead_xchacha20poly1305_ietf_encrypt(padded, AD, nonce, K) // incluye tag de 16 bytes al final
senderType∈user|company;keyVersionen decimal sin ceros a la izquierda- AD de ejemplo:
SecureChat|v1|msg|cnv_01JG7X8M2K9P3Q4R5S6T7U8V9W|user|cli_0001|1 - Relleno:
paddedLength(n) = ceil((n + 1) / 256) * 256. Siempre hay al menos el byte0x80: 255 bytes de texto → 256; 256 bytes → 512; texto vacío → 256 - Texto ≤ 64 KiB (65.536 bytes UTF-8) antes de rellenar
- El AD liga el ciphertext a su contexto: si alguien lo mueve de conversación, invierte el
remitente, cambia el
clientMessageIdo la versión, el descifrado falla
5.3 Descifrar un mensaje del usuario
Exactamente en este orden (el orden decide qué código devuelven los vectores negativos):
1. v == 1 y scheme == "sc1" → si no, UNSUPPORTED_VERSION
2. AD con conversationId DEL EVENTO, senderType = "user" (fijo),
clientMessageId y keyVersion DEL SOBRE (validados) → INVALID_INPUT
3. nonce = b64uDecode(nonce), exactamente 24 bytes → INVALID_INPUT
4. ct = b64uDecode(ciphertext) → INVALID_INPUT
len(ct) ≥ 272 y (len(ct) − 16) % 256 == 0 → si no, DECRYPT_FAILED
5. padded = crypto_aead_xchacha20poly1305_ietf_decrypt(ct, AD, nonce, K) → falla: DECRYPT_FAILED
6. quitar relleno: saltar 0x00 del final; debe quedar 0x80; el relleno no supera 256 bytes;
len(padded) múltiplo de 256 → si no, BAD_PADDING
7. UTF-8 estricto → si no, INVALID_INPUT
senderType lo fijas tú, no lo lees del evento. En un webhook siempre es user. En el
historial, usa el senderType del propio mensaje (company para los tuyos), porque así lo
cifró quien lo escribió.
5.4 Cifrar y enviar tu respuesta
clientMessageId = "srv_" + b64u(16 bytes aleatorios) // cualquier valor único que cumpla el regex de ids
encryption = sobre con senderType = "company", keyVersion = el de la envoltura, nonce aleatorio
POST /v1/messages
{ "conversationId": "cnv_…", "contentType": "text", "encryption": { … } }
POST /v1/messageses idempotente porclientMessageIddentro de la conversación (y porIdempotency-Key). Al reintentar, reenvía el mismo sobre- Nunca reutilices un nonce con la misma K. 24 bytes aleatorios de un CSPRNG bastan
- El servidor valida la forma (
ciphertext≥ 272 bytes,(len − 16) % 256 == 0, máx. 66 KB,noncede 24 bytes) pero no puede leer el contenido. Errores:409 CONVERSATION_PENDING,409 CONVERSATION_AWAITING_KEY,400 INVALID_ENVELOPE,409 KEY_VERSION_MISMATCH,410 USER_DELETED
Otros endpoints útiles, todos firmados igual: GET /v1/conversations[/{id}],
GET /v1/conversations/{id}/messages?limit=&before=&after=, POST …/read
({ "upToMessageId" }), POST …/typing, POST …/close, GET /v1/deliveries. Detalle en
la referencia de la API (disponible en el portal de empresas).
6. Adjuntos
Cada archivo se cifra con su propia clave F (32 bytes aleatorios) y un nonceBase
(24 bytes aleatorios), en trozos de 64 KiB. F y el resto de datos viajan dentro de un
mensaje normal cifrado con K (contentType: "attachment"); el servidor solo ve bytes opacos.
6.1 Cifrado por trozos
CHUNK = 65536
chunks = max(1, ceil(size / CHUNK)) // un archivo vacío es 1 trozo de 0 bytes
nonce_i = nonceBase[0..16) ‖ u64be(i) // i = 0, 1, 2…
AD_i = "SecureChat|v1|att|" + attachmentId + "|" + i + "|" + (i == chunks−1 ? "1" : "0")
ct_i = crypto_aead_xchacha20poly1305_ietf_encrypt(plain_i, AD_i, nonce_i, F) // len(plain_i) + 16
ciphertext = ct_0 ‖ ct_1 ‖ …
attachmentCiphertextLength(size) = size + chunks * 16
digest = b64u( BLAKE2b-256(plaintext completo, sin clave) )
El AD liga cada trozo a su archivo, su posición y al final del archivo: reordenar, recortar o
añadir trozos falla. Al descifrar: comprueba primero que len(ciphertext) == attachmentCiphertextLength(meta.size), descifra cada trozo y al final compara la huella.
Cualquier fallo → DECRYPT_FAILED; nunca entregues el archivo a medias.
6.2 La meta (texto del mensaje attachment)
El texto que cifras con el sobre de §5 es este JSON (mismo orden de campos que envelope.js):
{
"v": 1, "kind": "attachment", "attachmentId": "att_…", "name": "factura.pdf",
"mime": "application/pdf", "size": 150000, "key": "b64u(F)", "nonce": "b64u(nonceBase)",
"digest": "b64u(BLAKE2b-256)", "chunkSize": 65536,
"caption": "opcional", "width": 1200, "height": 800
}
Validación (validateAttachmentMeta), igual al enviar y al recibir:
| Campo | Regla | Error |
|---|---|---|
v, kind | 1 y "attachment" | UNSUPPORTED_VERSION |
attachmentId | ^att_[A-Za-z0-9_-]{8,64}$ | INVALID_INPUT |
name | string no vacío tras trim(), ≤ 255 unidades UTF-16, sin \u0000–\u001f, / ni \ | INVALID_INPUT |
mime | ^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}/[a-z0-9][a-z0-9!#$&^_.+-]{0,127}$ | INVALID_INPUT |
size | entero 0 … 26.214.400 (25 MB) | INVALID_INPUT |
chunkSize | exactamente 65536 | UNSUPPORTED_VERSION |
key, nonce, digest | base64url de 32, 24 y 32 bytes | INVALID_INPUT |
caption | opcional; string ≤ 4000 unidades UTF-16 (null = ausente) | INVALID_INPUT |
width, height | opcionales pero juntos; enteros 1 … 20000 | INVALID_INPUT |
El JSON se parsea, no se compara byte a byte: el orden y el escapado no afectan a la compatibilidad.
6.3 Enviar un adjunto
POST /v1/conversations/{id}/attachmentscon{ "size": attachmentCiphertextLength(bytes) }(tamaño del ciphertext, entre 16 y 26.220.800) →201 { "attachmentId", "uploadUrl", "method": "PUT", "headers": { … }, "expiresIn": 300 }- Cifra con ese
attachmentId(va en el AD de cada trozo) PUTauploadUrlcon elmethody exactamente lasheadersdevueltas (hoyContent-Type: application/octet-streamyx-amz-tagging: state=pending) y un cuerpo de exactamentesizebytes. Es una URL prefirmada de S3: sinAuthorizationni firma HMAC. Cualquier diferencia →403de S3- Cifra
JSON(meta)como respuesta normal (§5.4) y envía:
{ "conversationId": "cnv_…", "contentType": "attachment",
"attachment": { "attachmentId": "att_…" }, "encryption": { … } }
Errores propios: 409 ATTACHMENT_NOT_UPLOADED, 409 ATTACHMENT_ALREADY_USED. Límite: 1.000
subidas por empresa y hora.
6.4 Recibir un adjunto
message.created con data.contentType: "attachment" trae además, en claro,
data.attachment: { attachmentId, size } (size = bytes del ciphertext).
- Descifra
data.encryptioncomo cualquier mensaje y parsea/valida la meta - Comprueba que
meta.attachmentId == data.attachment.attachmentId(si no,KEY_MISMATCH) GET /v1/conversations/{id}/attachments/{attachmentId}(firmado) →{ "downloadUrl", "size", "expiresIn": 300 }GET downloadUrlsin cabeceras propias y descifra con la meta del mensaje (§6.1)
Un contentType desconocido se ignora.
7. Rotación de tu llave y firmas Ed25519
7.1 Rotar
Generas una llave X25519 nueva y firmas el paso con tu signing.key:
statement = "SecureChat|v1|company-key-rotation|" + companyId + "|" + fromKeyVersion + "|" + b64u(fromPublicKey)
+ "|" + toKeyVersion + "|" + b64u(toPublicKey)
rotationSignature = b64u( Ed25519_sign_detached(UTF8(statement), signing.key) ) // 64 bytes
toKeyVersion > fromKeyVersion(si no,INVALID_INPUT);securechat rotateusafrom + 1- Pegas la llave pública nueva y
rotationSignatureen el portal (Llaves y API → Rotar) - La app fija tu
signingPublicKeyen el primer contacto: una rotación firmada por ella se muestra como aviso suave; sin firma, el aviso es prominente - Conserva la llave anterior y pásala a tu lista de llaves (§4.2): las conversaciones existentes siguen selladas para ella
Verificar es lo inverso: Ed25519_verify_detached(sig, statement, signing.pub) →
SIGNATURE_INVALID si no verifica.
7.2 Firma de la plataforma (informativa)
SecureChat firma tu llave pública y la app lo comprueba con la llave de plataforma fijada en el binario. No necesitas implementarlo, pero los vectores lo incluyen:
statement = "SecureChat|v1|company-key|" + companyId + "|" + keyVersion + "|" + b64u(publicKey)
7.3 Agentes de la bandeja web (lista firmada)
Si tu equipo responde desde la bandeja del portal, cada persona tiene su propia llave X25519,
generada en su navegador, y la app sella la clave de conversación también para ella. Solo lo
hace con las llaves de una lista de agentes firmada con tu signing.key: SecureChat no
puede colar una llave suya.
agents = por cada agente, agentKeyId + ":" + b64u(publicKey), ordenados por agentKeyId
(orden de bytes) y unidos por "," // lista vacía → ""
statement = "SecureChat|v1|agent-roster|" + companyId + "|" + rosterVersion + "|" + issuedAt + "|" + agents
signature = b64u( Ed25519_sign_detached(UTF8(statement), signing.key) ) // 64 bytes
Ejemplo (vector two-agents):
SecureChat|v1|agent-roster|cmp_8f3a2b1c|3|2026-05-04T15:30:00Z|agk_vectorAAAA0001:loI-7Zl6tCQzkXROAvWMFCCvoHxjGqdbIMB567nwsAk,agk_vectorBBBB0002:BSo3nchkrfA_2psM6_9aytexQxJHIwEh8lGMdjMM2lM
agentKeyId:agk_+ 8 a 64 caracteres[A-Za-z0-9_-];publicKey: 32 bytes. Sin ids ni llaves repetidos y como mucho 1000 agentes. La lista vacía es válida (sin agentes: el enunciado termina en|)rosterVersion: entero de 1 a 2^31-1; cada lista nueva lleva la anterior + 1.issuedAt:YYYY-MM-DDTHH:MM:SSZexacto (UTC, sin milisegundos ni zona, fecha que exista). Cualquier otra cosa →INVALID_INPUT- Lo normal es que firme el owner en el portal (Equipo → Agentes): carga
signing.keyen el navegador, que no se sube. Si prefieres firmar en tu servidor, el formato es este mismo - Verificar:
Ed25519_verify_detached(sig, statement, signing.pub)→SIGNATURE_INVALIDsi no verifica. La app la verifica con lasigningPublicKeyque fijó en el primer contacto y nunca sella para una lista que no verifique ni para una versión menor que la última que vio - La envoltura para un agente es una envoltura normal (§4.1) sellada para su
publicKey. Para dar acceso a un agente nuevo a conversaciones anteriores desde tu servidor: abres K con tu llave (§4.2), la sellas para cada agente nuevo de la lista y la subes conPOST /v1/conversations/{id}/agent-wraps
8. Cómo validar tu implementación
8.1 v1.json
Generado con libsodium 1.0.22 (libsodium-wrappers). Llaves solo de prueba. Todo el
material va en base64url sin relleno. Estructura:
| Clave | Contenido | Comprobaciones |
|---|---|---|
keys.{user,company,recoveryDevice} | seed, publicKey, privateKey | crypto_box_seed_keypair(seed) da exactamente ese par (sk = SHA-512(seed)[0..32), pk = X25519(sk, 9)) · 3 |
keys.platformSigning | seed, publicKey Ed25519 | (para companyKey) |
conversation | conversationId, companyId, keyVersion, key (K) | contexto común |
messages[] | name, senderType, clientMessageId, keyVersion, text, nonce, ad, paddedLength, ciphertext, envelope | tu AD == ad; cifrar con ese nonce da exactamente ciphertext; longitud = paddedLength + 16; descifrar envelope da text · 7 (vacío, unicode, 255/256 bytes, 3000 bytes) |
negativeMessages[] | conversationId, senderType, envelope, key?, expectError | descifrar debe fallar con ese código · 17 |
wraps[] | recipient (qué par de keys usar), wrapped, expectConversationId, expectError? | abrir da K, o falla con el código · 7 |
companyKey | firma de plataforma válida + negative[] | verifica / SIGNATURE_INVALID · 5 |
companyRotation | signingSeed, signingPublicKey, rotación válida + negative[] con expectError | 7 |
agentRoster | signingPublicKey (la de companyRotation), agentKeys.{agentA,agentB,agentC} (agentKeyId, seed, publicKey, privateKey), valid[] con statement y signature | tu enunciado == statement; firmar con companyRotation.signingSeed da exactamente signature; verifica (con los agentes desordenados también) · 3 |
agentRoster.negative[] | lista con signature y expectError: llave cambiada, agente añadido o quitado, otra versión, otra fecha, otra empresa, firmada por la plataforma, firma manipulada (→ SIGNATURE_INVALID); id o llave repetidos, id mal formado, fecha imposible, fecha con milisegundos, versión 0 (→ INVALID_INPUT) | 14 |
agentWraps[] | recipient (qué par de agentRoster.agentKeys usar), wrapped, expectConversationId, expectError? | abrir da K; con la llave de otro agente → DECRYPT_FAILED; en otra conversación → KEY_MISMATCH · 4 |
attachments | key (F), nonceBase, pattern (byte[i] = (i*31 + 7) & 255) y cases[] con size, attachmentId, meta, ciphertextLength, ciphertextDigest (BLAKE2b-256 del ciphertext), ciphertext (solo los pequeños) | cifrar el patrón con F y nonceBase da ese digest y esa meta; descifrar vuelve al patrón · 5 |
(derivados de attachments) | sobre three-chunks: byte invertido, último trozo recortado, trozos intercambiados, otro attachmentId, otra huella, otro tamaño (→ DECRYPT_FAILED) y nombre ../x (→ INVALID_INPUT) | 7 |
companyKey.fingerprint | huella b64u de companyKey.publicKey | 1 |
inviteBinding | inviteSecret, conversationId, userPublicKey, wrapForCompany, tag | tag exacto y verifica; con la llave de recoveryDevice en lugar de la del usuario → BINDING_INVALID · 2 |
Total: 82. La lista exacta de comprobaciones está en run-vectors.js:
pórtala tal cual (los ejemplos PHP y Go lo hacen). Las comprobaciones negativas no son
opcionales: un descifrador que "funciona" con los vectores positivos pero acepta un sobre
reetiquetado no es compatible.
Si tu implementación pasa los 82 vectores, es compatible con la app y con los SDK.
8.2 Firmas HMAC
v1.json no tiene vectores de firma. signing.json
(generado con el SDK de Node) trae 12 webhooks
(válidos, manipulados, reserializados, caducados, malformados, con el código de error que da el
SDK de Node) y 5 peticiones /v1 con su canónico y su cabecera exacta. 17 casos.
8.3 Prueba cruzada
La prueba cruzada genera llaves con la CLI real (keygen + rotate),
cifra en el lado "app" con envelope.js, y comprueba que PHP y Go descifran, responden,
resellan, firman rotaciones y cifran adjuntos que el SDK de Node acepta. Luego levanta el receptor
de ejemplo de cada lenguaje contra una API /v1 simulada que verifica las firmas con
la implementación del servidor.
9. Librerías por lenguaje
Todas envuelven libsodium o implementan lo mismo. Las versiones son las últimas vistas al escribir esto: comprueba la actual antes de fijarla.
| Primitiva | PHP (ext-sodium, en el núcleo desde 7.2) | Go (golang.org/x/crypto + stdlib) | Java (Lazysodium, com.goterl:lazysodium-java, últ. vista 5.2.0) | .NET (Sodium.Core, últ. vista 1.4.1) | Ruby (rbnacl, últ. vista 7.1.2) |
|---|---|---|---|---|---|
Abrir envoltura (crypto_box_seal_open) | sodium_crypto_box_seal_open($c, sodium_crypto_box_keypair_from_secretkey_and_publickey($sk, $pk)) → false si falla | box.OpenAnonymous(nil, c, &pk, &sk) (nacl/box) | cryptoBoxSealOpen(m, c, cLen, pk, sk) | SealedPublicKeyBox.Open(c, sk, pk) | RbNaCl::Boxes::Sealed.from_private_key(sk).open(c) |
| Sellar (rewrap) | sodium_crypto_box_seal($m, $pk) | box.SealAnonymous(nil, m, &pk, rand.Reader) | cryptoBoxSeal(c, m, mLen, pk) | SealedPublicKeyBox.Create(m, pk) | Sealed.from_public_key(pk).box(m) |
| XChaCha20-Poly1305 IETF | sodium_crypto_aead_xchacha20poly1305_ietf_{encrypt,decrypt}($m, $ad, $nonce, $k) | chacha20poly1305.NewX(k) → Seal/Open | cryptoAeadXChaCha20Poly1305Ietf{Encrypt,Decrypt}(…) | SecretAeadXChaCha20Poly1305.{Encrypt,Decrypt}(m, nonce, k, ad) | RbNaCl::AEAD::XChaCha20Poly1305IETF.new(k).{encrypt,decrypt}(nonce, m, ad) |
| BLAKE2b-256 (huella, digest) / con clave (vinculación) | sodium_crypto_generichash($m, $key = '', 32) | blake2b.Sum256(m) / blake2b.New(32, key) | cryptoGenericHash(out, 32, m, mLen, key, keyLen) | GenericHash.Hash(m, key /* o null */, 32) | RbNaCl::Hash.blake2b(m, digest_size: 32, key: k) |
| Ed25519 verificar / firmar | sodium_crypto_sign_verify_detached($sig, $m, $pk) / sodium_crypto_sign_detached($m, $sk64) | ed25519.Verify(pk, m, sig) / ed25519.Sign(sk64, m) (stdlib) | cryptoSignVerifyDetached(sig, m, mLen, pk) / cryptoSignDetached(…) | PublicKeyAuth.VerifyDetached(sig, m, pk) / PublicKeyAuth.SignDetached(m, sk64) | RbNaCl::VerifyKey.new(pk).verify(sig, m) / RbNaCl::SigningKey.new(sk64[0,32]).sign(m) |
| HMAC-SHA256 + comparación | hash_hmac('sha256', …) + hash_equals | crypto/hmac + hmac.Equal | javax.crypto.Mac("HmacSHA256") + MessageDigest.isEqual | HMACSHA256 + CryptographicOperations.FixedTimeEquals | OpenSSL::HMAC.hexdigest("SHA256", …) + Rack::Utils.secure_compare / OpenSSL.fixed_length_secure_compare |
| base64url sin relleno | sodium_bin2base64(…, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING) | base64.RawURLEncoding.Strict() | Base64.getUrlEncoder().withoutPadding() | Utilities.BinaryToBase64(b, Utilities.Base64Variant.UrlSafeNoPadding) | Base64.urlsafe_encode64(b, padding: false) |
| Ejemplo oficial | Sí, probado: descargas | Sí, probado: descargas | Sin ejemplo oficial todavía | Sin ejemplo oficial todavía | Sin ejemplo oficial todavía |
Notas por lenguaje:
- PHP: las funciones de AEAD y
seal_opendevuelvenfalseal fallar, pero lanzanSodiumExceptioncon longitudes inválidas: trata ambos casos comoDECRYPT_FAILED.pack('J')(u64 big-endian) requiere PHP de 64 bits. Referencias: sodium_crypto_box_seal_open, sodium_crypto_aead_xchacha20poly1305_ietf_decrypt, sodium_crypto_generichash, sodium_crypto_sign_verify_detached - Go: Go puro, sin cgo.
nacl/boxtraeSealAnonymous/OpenAnonymous, compatibles concrypto_box_seal. El decodificador base64 de Go ignora\ry\nincluso en modoStrict: valida el alfabeto antes.crypto_box_seed_keypairno existe como tal: essk = SHA-512(seed)[:32],pk = curve25519.X25519(sk, Basepoint). Referencias: nacl/box, chacha20poly1305, blake2b, crypto/ed25519 - Java: Lazysodium (JNA sobre libsodium) cubre todo. Bouncy Castle y la JCE estándar traen X25519, Ed25519, BLAKE2b y ChaCha20-Poly1305 con nonce de 12 bytes, pero no el sealed box ni XChaCha20-Poly1305 listos para usar: tendrías que construirlos a mano (HChaCha20, nonce BLAKE2b-192 + XSalsa20-Poly1305). No lo recomendamos
- .NET: Sodium.Core tiene todas las piezas, incluido
SealedPublicKeyBox, pero su propio README indica que ya no tiene desarrollo activo. NSec es moderno y traeAeadAlgorithm.XChaCha20Poly1305, X25519, Ed25519 y BLAKE2b, pero no el sealed box: combinarlos exige implementarcrypto_box_seal_opena mano - Ruby: rbnacl necesita libsodium instalado en el sistema.
SigningKey.newrecibe la semilla de 32 bytes (los primeros 32 designing.key).VerifyKey#verifylanzaRbNaCl::BadSignatureErrorsi no verifica - Cualquier otro lenguaje: busca un binding de libsodium en la lista oficial y pasa los 82 vectores
10. Errores frecuentes
| Síntoma | Causa habitual |
|---|---|
401 INVALID_SIGNATURE en todas tus peticiones | Firmas una ruta distinta de la que envías (query reordenada o recodificada), METHOD en minúsculas, o el cuerpo firmado no es byte a byte el enviado |
401 SIGNATURE_EXPIRED | Reloj del servidor desfasado más de 300 s |
| Todos los webhooks fallan la firma | Verificas sobre el JSON re-serializado, o usas el secreto sin el prefijo whsec_ |
DECRYPT_FAILED en todos los mensajes | senderType equivocado en el AD, conversationId de otra fuente, o descifraste con la llave X25519 vieja sin probar la lista |
BAD_PADDING / texto con basura al final | No quitas el relleno ISO 7816-4 o lo quitas recortando \0 sin buscar el 0x80 |
| La app no muestra tu respuesta | keyVersion distinto del de la envoltura, clientMessageId repetido (devuelve el mensaje anterior) o sin relleno (400 INVALID_ENVELOPE) |
403 al subir un adjunto | Faltan las headers exactas de uploadUrl o el cuerpo no mide size bytes |
Regex de ids acepta cli_1\n | PHP: falta el modificador D. Go/Java/.NET: usa anclas de fin de texto |
11. Archivos de referencia y ejemplos
Material para validar y portar tu implementación, tal cual está en el repositorio:
| Archivo | Qué es |
|---|---|
v1.json | Los 82 vectores de prueba (§8.1). Llaves solo de prueba |
run-vectors.js | La lista exacta de comprobaciones de los vectores, para portarla |
envelope.js | Implementación de referencia (JavaScript): sobre, envolturas, adjuntos, huellas y firmas |
signing.json | Los 17 casos de firma HMAC (§8.2) |
PHP (PHP ≥ 8.1 con ext-sodium). Todo junto en securechat-php.zip, con v1.json y signing.json en las rutas que esperan los ejemplos (cd securechat-php/examples/other-languages/php && php run-vectors.php):
securechat.php: librería de un solo archivo, sin Composerindex.php: receptor de webhooks en PHP planorun-vectors.php: ejecuta los vectores; buscav1.jsonen../../../packages/crypto/vectors/ysigning.jsonen../fixtures/
Go (Go ≥ 1.26, solo golang.org/x/crypto). Todo junto en securechat-go.zip, con v1.json y signing.json en las rutas que esperan los ejemplos (cd securechat-go/examples/other-languages/go && go test ./...):
go.mod·go.sum- Paquete
securechat/:attachments.go,client.go,codec.go,envelope.go,events.go,keys.go,signing.goyvectors_test.go(go test ./...; las rutas de los vectores se cambian conSECURECHAT_VECTORSySECURECHAT_SIGNING_FIXTURES) cmd/webhook/main.go: receptor de webhooks connet/http
Los ejemplos son material de referencia: en producción, guarda los eventId procesados y los
inviteSecret en tu base de datos, procesa los webhooks en una cola y sirve el receptor por HTTPS.