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:

FuenteQué define
envelope.jsEl sobre v1 sc1, envolturas, adjuntos, huellas, firmas Ed25519. Fuente única de verdad
v1.json82 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 servidorCó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:

  1. Leer tus llaves (private.key/public.key, y signing.key si rotas) — §2
  2. Verificar la firma HMAC de cada webhook y firmar cada petición a /v1 — §3
  3. Abrir la clave de conversación sellada para ti y verificar la vinculación de la invitación — §4
  4. Descifrar los mensajes del usuario y cifrar tus respuestas — §5
  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 variante URLSAFE_NO_PADDING de libsodium. El decodificador debe ser estricto: rechaza =, espacios, saltos de línea, caracteres de otro alfabeto, longitud % 4 == 1 y 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 modificador D (o \z); sin él, $ acepta un \n final
  • 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, chunkSize son enteros. La plataforma nunca los emite como 1.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ódigoSignifica
INVALID_INPUTFormato inválido: base64url, longitud de llave/nonce, id fuera de [A-Za-z0-9_-], UTF-8
UNSUPPORTED_VERSIONv ≠ 1 o scheme ≠ "sc1" (o meta de adjunto de otra versión)
DECRYPT_FAILEDLa autenticación falló: llave equivocada, manipulación, longitud imposible
KEY_MISMATCHLa envoltura abre con tu llave, pero es de otra conversación o versión
BAD_PADDINGDescifra bien pero el relleno no es ISO 7816-4 válido
SIGNATURE_INVALIDFirma Ed25519 que no verifica
BINDING_INVALIDLa 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.

ArchivoContenidoBytesPermisosVa al portal
private.keySecreta X25519 (crypto_box_keypair)320600Nunca
public.keyPública X25519320644Sí: Llaves y API
signing.keySecreta Ed25519 (crypto_sign_keypair) en formato libsodium: semilla(32) ‖ pública(32)640600Nunca
signing.pubPública Ed25519320644Sí, 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 como private.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 imprime keygen y securechat fingerprint public.key

2.2 Credenciales del portal

CredencialFormatoUso
API keysk_live_<base64url de 32 bytes>Authorization: Bearer … en /v1. Se muestra una sola vez
webhookSecretwhsec_<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) ) )
PiezaRegla
tfloor(now/1000) en segundos. El backend acepta |ahora − t| ≤ 300
METHODEn mayúsculas: GET, POST
pathAndQueryExactamente 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)
rawBodyLos 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_SIGNATURE o 401 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):

  1. 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
  2. Parsear la cabecera: separa por ,; cada parte por el primer =; recorta espacios de clave y valor. Necesitas t (entero) y v1 (exactamente ^[0-9a-f]{64}$, minúsculas). Falta la cabecera → SIGNATURE_MISSING; formato inválido → SIGNATURE_MALFORMED
  3. Ventana: rechaza si |ahora − t| > 300 s (SIGNATURE_EXPIRED). Evita replays
  4. Recalcular hex(HMAC-SHA256(webhookSecret, t + "." + rawBody))
  5. Comparar en tiempo constante (hash_equals, hmac.Equal, MessageDigest.isEqual, CryptographicOperations.FixedTimeEquals, Rack::Utils.secure_compare). Distinto → SIGNATURE_INVALID y responde 401
  6. Idempotencia por eventId: la entrega es "al menos una vez". Si ya lo procesaste, responde 200 y no hagas nada
  7. 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
  • userPublicKey sale de data.userPublicKey (decodifícalo y vuelve a codificarlo, o usa la cadena tal cual si ya validaste que es base64url de 32 bytes); wrapForCompany es data.conversationKey, con sealed tal 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.started con data.isReinvite: true, el userPublicKey nuevo y en conversationKey la 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

NombreQué versionaDónde aparece
keyVersion de la conversaciónLa clave KEnvolturas, sobre de mensaje, AD, conversation.keyVersion. Hoy siempre 1
keyVersion de la empresaTu llave X25519Portal, 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; keyVersion en 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 byte 0x80: 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 clientMessageId o 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/messages es idempotente por clientMessageId dentro de la conversación (y por Idempotency-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, nonce de 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:

CampoReglaError
v, kind1 y "attachment"UNSUPPORTED_VERSION
attachmentId^att_[A-Za-z0-9_-]{8,64}$INVALID_INPUT
namestring 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
sizeentero 0 … 26.214.400 (25 MB)INVALID_INPUT
chunkSizeexactamente 65536UNSUPPORTED_VERSION
key, nonce, digestbase64url de 32, 24 y 32 bytesINVALID_INPUT
captionopcional; string ≤ 4000 unidades UTF-16 (null = ausente)INVALID_INPUT
width, heightopcionales pero juntos; enteros 1 … 20000INVALID_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

  1. POST /v1/conversations/{id}/attachments con { "size": attachmentCiphertextLength(bytes) } (tamaño del ciphertext, entre 16 y 26.220.800) → 201 { "attachmentId", "uploadUrl", "method": "PUT", "headers": { … }, "expiresIn": 300 }
  2. Cifra con ese attachmentId (va en el AD de cada trozo)
  3. PUT a uploadUrl con el method y exactamente las headers devueltas (hoy Content-Type: application/octet-stream y x-amz-tagging: state=pending) y un cuerpo de exactamente size bytes. Es una URL prefirmada de S3: sin Authorization ni firma HMAC. Cualquier diferencia → 403 de S3
  4. 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).

  1. Descifra data.encryption como cualquier mensaje y parsea/valida la meta
  2. Comprueba que meta.attachmentId == data.attachment.attachmentId (si no, KEY_MISMATCH)
  3. GET /v1/conversations/{id}/attachments/{attachmentId} (firmado) → { "downloadUrl", "size", "expiresIn": 300 }
  4. GET downloadUrl sin 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 rotate usa from + 1
  • Pegas la llave pública nueva y rotationSignature en el portal (Llaves y API → Rotar)
  • La app fija tu signingPublicKey en 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:SSZ exacto (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.key en 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_INVALID si no verifica. La app la verifica con la signingPublicKey que 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 con POST /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:

ClaveContenidoComprobaciones
keys.{user,company,recoveryDevice}seed, publicKey, privateKeycrypto_box_seed_keypair(seed) da exactamente ese par (sk = SHA-512(seed)[0..32), pk = X25519(sk, 9)) · 3
keys.platformSigningseed, publicKey Ed25519(para companyKey)
conversationconversationId, companyId, keyVersion, key (K)contexto común
messages[]name, senderType, clientMessageId, keyVersion, text, nonce, ad, paddedLength, ciphertext, envelopetu 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?, expectErrordescifrar 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
companyKeyfirma de plataforma válida + negative[]verifica / SIGNATURE_INVALID · 5
companyRotationsigningSeed, signingPublicKey, rotación válida + negative[] con expectError7
agentRostersigningPublicKey (la de companyRotation), agentKeys.{agentA,agentB,agentC} (agentKeyId, seed, publicKey, privateKey), valid[] con statement y signaturetu 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
attachmentskey (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.fingerprinthuella b64u de companyKey.publicKey1
inviteBindinginviteSecret, conversationId, userPublicKey, wrapForCompany, tagtag 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.

PrimitivaPHP (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 fallabox.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 IETFsodium_crypto_aead_xchacha20poly1305_ietf_{encrypt,decrypt}($m, $ad, $nonce, $k)chacha20poly1305.NewX(k) → Seal/OpencryptoAeadXChaCha20Poly1305Ietf{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 / firmarsodium_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ónhash_hmac('sha256', …) + hash_equalscrypto/hmac + hmac.Equaljavax.crypto.Mac("HmacSHA256") + MessageDigest.isEqualHMACSHA256 + CryptographicOperations.FixedTimeEqualsOpenSSL::HMAC.hexdigest("SHA256", …) + Rack::Utils.secure_compare / OpenSSL.fixed_length_secure_compare
base64url sin rellenosodium_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 oficialSí, probado: descargasSí, probado: descargasSin ejemplo oficial todavíaSin ejemplo oficial todavíaSin ejemplo oficial todavía

Notas por lenguaje:

  • PHP: las funciones de AEAD y seal_open devuelven false al fallar, pero lanzan SodiumException con longitudes inválidas: trata ambos casos como DECRYPT_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/box trae SealAnonymous/OpenAnonymous, compatibles con crypto_box_seal. El decodificador base64 de Go ignora \r y \n incluso en modo Strict: valida el alfabeto antes. crypto_box_seed_keypair no existe como tal: es sk = 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 trae AeadAlgorithm.XChaCha20Poly1305, X25519, Ed25519 y BLAKE2b, pero no el sealed box: combinarlos exige implementar crypto_box_seal_open a mano
  • Ruby: rbnacl necesita libsodium instalado en el sistema. SigningKey.new recibe la semilla de 32 bytes (los primeros 32 de signing.key). VerifyKey#verify lanza RbNaCl::BadSignatureError si no verifica
  • Cualquier otro lenguaje: busca un binding de libsodium en la lista oficial y pasa los 82 vectores

10. Errores frecuentes

SíntomaCausa habitual
401 INVALID_SIGNATURE en todas tus peticionesFirmas 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_EXPIREDReloj del servidor desfasado más de 300 s
Todos los webhooks fallan la firmaVerificas sobre el JSON re-serializado, o usas el secreto sin el prefijo whsec_
DECRYPT_FAILED en todos los mensajessenderType 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 finalNo quitas el relleno ISO 7816-4 o lo quitas recortando \0 sin buscar el 0x80
La app no muestra tu respuestakeyVersion distinto del de la envoltura, clientMessageId repetido (devuelve el mensaje anterior) o sin relleno (400 INVALID_ENVELOPE)
403 al subir un adjuntoFaltan las headers exactas de uploadUrl o el cuerpo no mide size bytes
Regex de ids acepta cli_1\nPHP: 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:

ArchivoQué es
v1.jsonLos 82 vectores de prueba (§8.1). Llaves solo de prueba
run-vectors.jsLa lista exacta de comprobaciones de los vectores, para portarla
envelope.jsImplementación de referencia (JavaScript): sobre, envolturas, adjuntos, huellas y firmas
signing.jsonLos 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 Composer
  • index.php: receptor de webhooks en PHP plano
  • run-vectors.php: ejecuta los vectores; busca v1.json en ../../../packages/crypto/vectors/ y signing.json en ../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 ./...):

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.