Integración de clientes con AeroNyx Chat Relay
Referencia de integración de AeroNyx Chat Relay: autenticación WebSocket, mensajes sellados 1:1 y de grupo, entrega sin conexión, confirmaciones, reacciones, estado de conexión, archivos adjuntos cifrados, notificaciones push y límites de frecuencia.
Preparar una instrucción de integración
Elige la tecnología y la tarea, y copia la instrucción a tu asistente de programación. Se genera localmente; no se envía a un servicio de IA.
Instrucción generada
Esta es la referencia de integración de AeroNyx Chat Relay: el servicio WebSocket y HTTPS en api.aeronyx.network que transporta mensajes 1:1 cifrados de extremo a extremo, mensajes de grupo, confirmaciones, reacciones, estado de conexión y archivos adjuntos cifrados entre identidades de AeroNyx.
Está dirigida a ingenieros que desarrollan clientes, bots y servicios compatibles con AeroNyx, y a agentes de programación con IA que los implementan. Cada trama, campo y límite de esta página refleja el relé de producción y la AeroNyx App a octubre de 2026.
Chat Relay es la ruta de entrega centralizada. Coexiste con la ruta descentralizada de nodos (enrutamiento cebolla y buzones anónimos; consulte Entrega verificada en dos saltos); un cliente puede usar ambas. Para una integración HTTPS de solicitud/respuesta sin WebSocket, consulte Central Chat HTTPS API v1.
Modelo de confianza
El relé no tiene visibilidad del contenido. Los clientes cifran y firman todo antes de que llegue al relé, y el relé enruta, encola y aplica límites de frecuencia a texto cifrado opaco.
El relé nunca recibe:
- texto de mensajes, emojis de reacciones, ediciones ni cargas útiles de grupo en texto claro
- claves de chat, claves de grupo, claves de archivos adjuntos ni nonces
- contenido de archivos adjuntos, nombres de archivo, miniaturas, formas de onda ni transcripciones
- claves privadas de identidad
El relé sí observa metadatos de entrega, y los integradores deben considerarlos visibles para el operador:
- claves públicas del remitente y del destinatario, ID de grupo e ID de mensaje
- marcas de tiempo, tamaños de carga útil y estado de entrega, confirmación y lectura
- estado de conexión, estado en primer plano e indicadores de escritura (enviados como tramas en texto claro)
- metadatos de señalización de llamadas (nombre de sala, ID de llamada, indicador de vídeo)
- tamaño del texto cifrado de los adjuntos, tipo de contenido declarado y caducidad
- el indicador
contact_requesty los metadatos de conexión a nivel de IP
La confidencialidad del contenido proviene del cifrado de extremo a extremo, no del control de acceso en el relé. Diseñe en consecuencia: un atacante que obtenga un texto cifrado almacenado debe seguir sin poder leerlo.
Identidades y claves
Una identidad de chat de AeroNyx es un par de claves Ed25519. La clave pública de 32 bytes, escrita como 64 caracteres hexadecimales en minúscula, es la dirección. Genere las claves en el dispositivo y nunca envíe la clave privada a ningún lugar.
A partir de un par de identidad se derivan dos claves:
- Clave de chat (1:1).
HKDF-SHA256(salt = empty, ikm = X25519(my_secret, peer_public), info = "AERONYX-P2P-KEY", length = 32), donde ambas claves Ed25519 se convierten a X25519 (SHA-512(seed)[0..32]con clamping para la clave secreta, conversión de Edwards a Montgomery para la clave pública). Ambos pares derivan la misma clave. - Firmas de mensajes. Ed25519 con la clave de identidad, sobre las secuencias exactas de bytes definidas en esta página.
Use siempre hexadecimal en minúscula para las claves públicas en las tramas. El relé no normaliza las mayúsculas y minúsculas en todas las claves de cola.
Autenticación
Firma RelayAuth
El inicio de sesión por WebSocket y todos los endpoints HTTPS autenticados usan la misma firma:
digest = SHA256("AeroNyx-RelayAuth-v1" || pubkey[32] || timestamp as u64 little-endian)
signature = Ed25519(identity_key, digest)
"AeroNyx-RelayAuth-v1" son los 20 bytes ASCII sin terminador. timestamp está en segundos Unix y debe estar a menos de 300 segundos de la hora del servidor.
La firma vincula únicamente la identidad y la hora. No vincula el método, la ruta, el cuerpo ni la conexión, y no hay nonce. Genere una marca de tiempo nueva para cada solicitud, envíela solo a través de TLS y nunca la registre en logs.
Encabezado HTTPS
Authorization: Relay <pubkey-hex>:<timestamp>:<signature-hex>
Una verificación fallida devuelve HTTP 401 con {"success": false, "error": "<reason>"}. Los motivos incluyen missing_auth_header, malformed_auth_header, invalid_timestamp, timestamp_expired, invalid_pubkey e invalid_signature.
Conexión WebSocket
Endpoint
wss://api.aeronyx.network/ws/relay/
Los clientes nativos se conectan sin encabezado Origin. Los navegadores deben conectarse desde un origen permitido; cualquier otro origen, o un Host desconocido, se cierra con el código 1008.
Inicio de sesión
El servidor acepta el socket y, a continuación, espera una trama auth en un plazo de 30 segundos:
{
"type": "auth",
"pubkey": "<64 hex>",
"timestamp": 1780000000,
"signature": "<128 hex>"
}
Éxito:
{ "type": "auth_ack", "session_id": "<uuid>", "server_ts": 1780000001 }
Use server_ts para estimar el desfase del reloj; las marcas de tiempo de los mensajes se comprueban con la misma ventana de ±300 segundos.
Tras auth_ack, el servidor inicia su latido, suscribe la conexión a sus canales y reproduce de inmediato la cola sin conexión (consulte Entrega sin conexión).
Un fallo envía {"type": "auth_error", "reason": "<reason>"} y cierra el socket con el código 4001. Motivos: missing_fields, timestamp_expired, invalid_pubkey, invalid_signature_length, invalid_signature_encoding, invalid_signature, internal_error. Si se agota el tiempo de inicio de sesión, se envía el motivo timeout y se cierra con 4002.
Cualquier otra trama anterior al inicio de sesión se responde con auth_error con el motivo authentication_required; el socket permanece abierto.
Latido y estado en primer plano
| Dirección | Trama | Comportamiento |
|---|---|---|
| servidor → cliente | {"type":"ping"} cada 30 s | Responda {"type":"pong"}. |
| cliente → servidor | {"type":"ping"} | El servidor responde {"type":"pong"}. |
| cliente → servidor | {"type":"presence_state","foreground":true} | Marca esta conexión como activa. |
| cliente → servidor | {"type":"presence_state","foreground":false} | La marca como en segundo plano, de modo que el relé puede enviar una notificación push para los mensajes nuevos. |
El relé considera que una identidad está en línea solo mientras su conexión envía ping, pong o presence_state con foreground: true al menos cada 90 segundos. La AeroNyx App envía un ping cada 15 segundos mientras está en primer plano y considera la conexión inactiva cuando se pierden más de tres pongs.
Las conexiones de larga duración deben reconectarse al menos una vez cada 24 horas. Los mensajes nunca se pierden cuando una conexión deja de recibir tramas en vivo sin aviso, porque cada mensaje también se encola y se reproduce al iniciar sesión, pero la entrega en vivo solo se reanuda tras la reconexión.
Reglas de las tramas
- Solo tramas de texto, un objeto JSON por trama. Las tramas binarias se ignoran.
- El tamaño máximo de trama es de 1.048.576 caracteres. Las tramas más grandes se rechazan con
{"type":"error","reason":"message_too_large"}. Mantengapayload_b64por debajo de unos 800 KiB para dejar espacio al resto de la trama. - Las tramas mal formadas devuelven
errorcon el motivoinvalid_json,invalid_json_typeounknown_type.
Trama de error genérica:
{ "type": "error", "reason": "<reason>", "retry_after": 0 }
Los errores por límite de frecuencia también incluyen scope.
Códigos de cierre
| Código | Significado |
|---|---|
1008 | Host u Origin no permitido. |
4000 | El servidor no pudo enviar su latido. |
4001 | El inicio de sesión falló. |
4002 | Se agotó el tiempo de inicio de sesión. |
Reconecte con retroceso exponencial y variación aleatoria (jitter). La AeroNyx App espera 2^(attempt-1) segundos, limitados al rango de 1 a 60 segundos, multiplicados por un factor aleatorio entre 0,8 y 1,2.
Envío de un mensaje 1:1
1. Construir el sobre sellado
La carga útil de un mensaje 1:1 es un ChatEnvelope firmado y cifrado. Su construcción exacta, una implementación de referencia en Python y un vector de prueba de referencia se encuentran en Central Chat HTTPS API v1: formato del sobre sellado. Ambas API usan el mismo sobre.
En resumen: XChaCha20-Poly1305 con la clave de chat, una firma Ed25519 sobre una transcripción de 121 bytes y un formato binario fijo. content_type es 0 para todos los mensajes, incluidos los que tienen archivos adjuntos.
El texto claro es el texto UTF-8 en un mensaje simple, o un objeto JSON en los mensajes con archivos adjuntos, respuestas, reenvíos o vistas previas de enlaces:
{
"type": "aeronyx_message",
"text": "See the attached file",
"attachments": [ { "blob_id": "...", "file_key": "...", "media_type": "image/jpeg", "file_name": "photo.jpg", "file_size": 482113 } ],
"reply": { "msg_id": "...", "sender_pubkey": "...", "text": "..." },
"forwarded": true,
"forwarded_from_name": "...",
"forwarded_from_pubkey": "...",
"link_preview": { "url": "https://...", "title": "...", "description": "...", "site_name": "..." }
}
Todos los campos salvo type y text son opcionales. Los receptores deben mostrar como texto simple cualquier texto claro que no sea un objeto JSON con "type": "aeronyx_message". El objeto de archivo adjunto se define en Archivos adjuntos cifrados.
2. Firmar la trama
Cada trama de mensaje lleva una segunda firma, payload_sig, que el relé verifica antes de aceptarla:
payload_sig = Ed25519(identity_key,
SHA256(receiver_pubkey[32] || sender_pubkey[32] || discriminant as 1 byte
|| timestamp as u64 little-endian || envelope_bytes))
envelope_bytes es el payload_b64 decodificado. Use el discriminante 11 para mensajes y ediciones, y 12 para reacciones. payload_sig se codifica en hexadecimal.
3. Enviar
{
"type": "relay_send",
"msg_id": "9e762ae0f5da43e6bec5eb8143f1f834",
"receiver_pubkey": "<64 hex>",
"discriminant": 11,
"payload_b64": "<base64 envelope>",
"timestamp": 1780000000,
"payload_sig": "<128 hex>"
}
| Campo | Reglas |
|---|---|
msg_id | 32 caracteres hexadecimales en minúscula: el message_id de 16 bytes del sobre. La App receptora almacena el mensaje con este ID, por lo que debe coincidir con el sobre. |
receiver_pubkey | La clave pública del destinatario. |
discriminant | 11. |
timestamp | Segundos Unix, dentro de ±300 s de la hora del servidor. |
suppress_push | Opcional. true no envía ninguna notificación push. |
contact_request | Opcional. Marca un primer mensaje a alguien que exige verificación de contacto (consulte Verificación de contactos). |
4. Acuse de recibo
{ "type": "relay_delivered", "msg_id": "...", "delivered": true, "queued": true, "durable": true }
durable(yqueued) estrueuna vez que el mensaje se ha almacenado en la cola sin conexión del destinatario. Tratedurable: truecomo «enviado».deliveredsignifica que el destinatario tenía una conexión activa en primer plano. No es prueba de recepción; para ello, use las confirmaciones de entrega.
Espere hasta 15 segundos al acuse de recibo antes de considerar fallido el intento.
Rechazos y errores
{ "type": "send_rejected", "msg_id": "...", "reason": "verification_required" }
| Respuesta | Motivo | Acción |
|---|---|---|
send_rejected | verification_required | El destinatario solo acepta mensajes de contactos. No reintente. |
send_rejected | contact_request_rate_limited | Se alcanzó el límite de solicitudes de contacto para este destinatario. No reintente. |
error | missing_fields | Falta un campo obligatorio o está vacío. |
error | invalid_timestamp, timestamp_expired | Corrija el reloj y vuelva a generar la marca de tiempo. |
error | invalid_payload_sig | payload_sig no supera la verificación. |
error | rate_limited | Reintente transcurridos retry_after segundos. |
Reintentos
Reintente un mensaje sin acuse de recibo con el mismo msg_id y los mismos bytes de sobre. Como el relé rechaza las tramas con más de 300 segundos de antigüedad, calcule un timestamp de trama y un payload_sig nuevos para cada reintento; el sobre conserva su marca de tiempo original. El relé y los receptores eliminan duplicados por msg_id.
Alternativa HTTPS
Cuando el WebSocket no está disponible, el mismo mensaje puede publicarse a través de HTTPS:
POST /api/relay/push/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: application/json
{
"receiver_pubkey": "<64 hex>",
"discriminant": 11,
"payload_b64": "<base64 envelope>",
"timestamp": 1780000000,
"payload_sig": "<128 hex>"
}
El éxito es HTTP 200 con {"success": true}. Los fallos de verificación de contacto devuelven 400 con verification_required o contact_request_rate_limited, y el límite de frecuencia devuelve 429 con Retry-After. Los mensajes enviados por esta vía se encolan para el destinatario, pero no generan una notificación push, así que vuelva a enviarlos por el WebSocket cuando se reconecte.
Recepción de mensajes
Los mensajes entrantes llegan como relay_envelope:
{
"type": "relay_envelope",
"sender_pubkey": "<64 hex>",
"discriminant": 11,
"payload_b64": "<base64 envelope>",
"timestamp": 1780000000,
"msg_id": "9e762ae0f5da43e6bec5eb8143f1f834",
"from_offline": false
}
Para discriminant: 11:
- Elimine duplicados por
msg_id. El mismo mensaje puede llegar en vivo y de nuevo desde la cola sin conexión. - Analice el sobre y compruebe que su
receiver_pubkeyes su identidad. - En las tramas en vivo (
from_offline: false), descarte el mensaje si la marca de tiempo del sobre difiere en más de 300 segundos de su reloj. - Verifique la firma del sobre con el
sender_pubkeydel sobre. Esa clave, y no elsender_pubkeyde la trama, es el remitente autenticado. - Descifre con la clave de chat derivada de ese remitente. Las versiones muy antiguas de la App cifraban con la salida X25519 sin procesar; pruébela si la clave HKDF falla.
- Almacene el mensaje de forma persistente y, a continuación, envíe una confirmación de entrega y, para
from_offline: true, un acuse de recibo de la cola sin conexión.
Los sobres 1:1 se entregan sin payload_sig; la firma del sobre es la comprobación de autenticidad. Las tramas también pueden incluir contact_request: true.
Si un mensaje no va dirigido a usted, no supera la verificación o no se puede descifrar, descártelo sin mostrar nada.
Entrega sin conexión
Cada mensaje, edición, revocación, reacción, confirmación de entrega y confirmación de lectura se escribe en la cola sin conexión del destinatario antes de la entrega en vivo. La cola se reproduce automáticamente tras cada inicio de sesión y bajo petición:
{ "type": "relay_pull" }
El relé reproduce todos los elementos encolados con sus tipos de trama habituales y from_offline: true, ordenados por marca de tiempo, seguidos de:
{ "type": "relay_pull_done", "count": 12, "has_more": false }
La entrega es de tipo «al menos una vez». Los elementos permanecen en la cola hasta que se confirma su recepción:
{ "type": "relay_offline_ack", "msg_id": "9e762ae0f5da43e6bec5eb8143f1f834" }
Confirme las reacciones con "reaction_id" en lugar de "msg_id". Confirme solo después de que el elemento se haya almacenado de forma persistente en el dispositivo. El relé responde con {"type":"relay_offline_ack","msg_id":"...","success":true}.
Límites de la cola:
| Límite | Valor |
|---|---|
| Elementos por destinatario | 1.000. Cuando está llena, los elementos nuevos se rechazan y el remitente recibe durable: false. |
| Retención | 72 horas desde que se añadió el elemento más reciente. |
| Tamaño de la carga útil | 1 MiB decodificado, dentro del límite de trama de 1 MiB. |
El relé es un búfer de entrega, no un historial de mensajes. Conserve el historial en el dispositivo.
Confirmaciones de entrega y de lectura
Confirmación de entrega
Envíela después de almacenar un mensaje:
{ "type": "message_receipt", "receiver_pubkey": "<original sender>", "msg_id": "...", "timestamp": 1780000010 }
El relé responde message_receipt_ack y entrega {"type":"message_receipt","sender_pubkey":"...","msg_id":"...","timestamp":...} al remitente original.
Confirmación de lectura
{ "type": "message_read", "receiver_pubkey": "<original sender>", "msg_id": "<latest read message>", "timestamp": 1780000200, "enabled": true }
Las confirmaciones de lectura funcionan como marcas de nivel: la App marca como leídos el mensaje indicado y todos los mensajes salientes anteriores de la conversación. Envíe una solo cuando el usuario tenga activadas las confirmaciones de lectura.
El relé aplica una regla de reciprocidad en el envío, en la entrega en vivo y en la reproducción. Una confirmación de lectura solo se entrega si ambos usuarios son contactos mutuos y ambos tienen read_receipts_enabled. En caso contrario, el remitente recibe:
{ "type": "message_read_ack", "msg_id": "...", "delivered": false, "suppressed": true, "reason": "receiver_read_receipts_disabled" }
| Motivo | Significado |
|---|---|
client_disabled | La trama incluía enabled: false o read_receipts_enabled: false. |
not_mutual_contact | Los usuarios no son contactos mutuos. |
reader_read_receipts_disabled | El lector tiene desactivadas las confirmaciones de lectura. |
receiver_read_receipts_disabled | El remitente original tiene desactivadas las confirmaciones de lectura. |
invalid_pubkey | Una clave pública está mal formada. |
Una confirmación de lectura entregada se confirma con {"type":"message_read_ack","msg_id":"...","delivered":true}.
Ediciones y revocaciones
Edición
Una edición es un nuevo sobre sellado (con su propio message_id aleatorio) que contiene el contenido de reemplazo completo, enviado con referencia al ID del mensaje original:
{
"type": "message_edit",
"receiver_pubkey": "<64 hex>",
"msg_id": "<original msg_id>",
"target_msg_id": "<original msg_id>",
"payload_b64": "<base64 envelope>",
"payload_sig": "<128 hex>",
"timestamp": 1780000400
}
payload_sig usa la fórmula de Firmar la trama con el discriminante 11. Una edición que elimina todos los archivos adjuntos establece "attachments_edited": true en su JSON en texto claro. El relé responde message_edit_ack. Los receptores verifican y descifran el sobre como un mensaje y solo deben aplicar una edición si su remitente escribió el mensaje original.
Revocación
{
"type": "message_revoke",
"receiver_pubkey": "<64 hex>",
"sender_pubkey": "<64 hex>",
"msg_id": "<original msg_id>",
"timestamp": 1780000500,
"payload_sig": "<128 hex>"
}
La firma de revocación se calcula sobre los bytes sin procesar, sin hash:
payload_sig = Ed25519(identity_key,
"aeronyx-message-revoke-v1" || sender_pubkey[32] || receiver_pubkey[32]
|| UTF-8(msg_id) || timestamp as u64 little-endian)
El relé responde message_revoke_ack. El relé no comprueba la autoría: los receptores deben verificar la firma y aplicar una revocación solo si su remitente escribió el mensaje original. Para eliminar los archivos adjuntos de un mensaje revocado, llame a POST /api/relay/blob/{blob_id}/delete/.
Reacciones
Una reacción 1:1 es un sobre sellado cuyo message_id es el ID de la reacción, con content_type 2 y el texto claro {"emoji": "❤️", "op": "add"} (o "remove"):
{
"type": "message_reaction",
"receiver_pubkey": "<64 hex>",
"msg_id": "<message being reacted to>",
"reaction_id": "<32 hex>",
"payload_b64": "<base64 envelope>",
"payload_sig": "<128 hex>",
"timestamp": 1780000300
}
payload_sigusa el discriminante12.reaction_ides la clave de idempotencia. Unreaction_idrepetido en un plazo de 72 horas se confirma con"duplicate": truey no se vuelve a entregar.- El relé responde
{"type":"message_reaction_ack","msg_id":"...","reaction_id":"...","delivered":...,"queued":...}. - Las reacciones están limitadas a 20 por remitente y conversación dentro de la ventana de frecuencia.
Las reacciones de grupo se describen en Grupos.
Estado de conexión e indicador de escritura
Las tramas de estado de conexión y de escritura son metadatos en texto claro y son visibles para el relé.
Estado de conexión
Suscríbase a los contactos:
{ "type": "presence_subscribe", "pubkeys": ["<64 hex>", "<64 hex>"] }
Se consideran hasta 200 claves por trama; las claves mal formadas y duplicadas se ignoran. Las suscripciones se acumulan durante toda la vida de la conexión. Suscríbase únicamente a sus propios contactos.
{
"type": "presence_subscribe_ack",
"count": 2,
"updates": [
{ "pubkey": "<a>", "visible": true, "presence_visible": true, "last_seen_visible": true, "online": false, "last_seen_ts": 1780000000, "reason": "allowed" },
{ "pubkey": "<b>", "visible": false, "presence_visible": false, "last_seen_visible": false, "reason": "not_mutual_contact" }
],
"server_ts": 1780000001
}
El estado de conexión solo es visible entre contactos mutuos, y solo si el destinatario de la suscripción tiene presence_enabled. Las entradas ocultas (reason not_mutual_contact o presence_hidden) no contienen online ni last_seen_ts. last_seen_ts solo está presente cuando last_seen_visible es true; en caso contrario, muestre un estado genérico como «visto recientemente».
Los cambios en vivo llegan como:
{ "type": "presence_update", "pubkey": "<a>", "online": true, "presence_visible": true, "last_seen_visible": true, "last_seen_ts": 1780000100 }
Indicador de escritura
{ "type": "typing", "receiver_pubkey": "<64 hex>", "is_typing": true }
{ "type": "group_typing", "group_id": "<uuid>", "is_typing": true }
El indicador de escritura solo se reenvía cuando el estado de conexión del remitente sería visible para el destinatario (contactos mutuos y presence_enabled), o a los demás miembros de un grupo al que pertenece el remitente. Nunca se almacena, se encola ni se envía como notificación push.
Perfil y configuración de privacidad
GET /api/relay/profile/
Authorization: Relay <pubkey>:<timestamp>:<signature>
{
"success": true,
"profile": {
"pubkey_hex": "<64 hex>",
"display_name": "AeroNyx User",
"bio": "",
"handle": "",
"avatar_url": "",
"status_text": "",
"privacy": { "presence_enabled": true, "last_seen_enabled": true, "read_receipts_enabled": true },
"updated_at": "2026-10-09T10:00:00Z"
}
}
Una identidad sin perfil recibe campos vacíos y los tres indicadores de privacidad en true.
PATCH /api/relay/profile/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: application/json
{ "display_name": "Alice", "privacy": { "presence_enabled": true, "last_seen_enabled": false, "read_receipts_enabled": false } }
| Campo | Reglas |
|---|---|
display_name | Hasta 50 caracteres. |
bio | Hasta 200 caracteres. |
avatar_url | URL https:// o vacío. |
handle | a-z y 0-9, de 5 a 24 caracteres. Los nombres de usuario están sujetos a reglas de membresía y a periodos de espera entre cambios. |
privacy.* | Booleanos. Los tres indicadores también pueden enviarse en el nivel superior. |
Los errores incluyen no_valid_fields, <flag>_invalid_boolean, handle_taken y handle_change_cooldown:<date>.
Mantenga el cliente coherente con esta configuración: no envíe confirmaciones de lectura cuando estén desactivadas y no muestre el estado de lectura de un interlocutor mientras sus propias confirmaciones de lectura estén desactivadas.
Verificación de contactos
Un usuario puede exigir que los desconocidos se verifiquen antes de enviarle mensajes. Cuando el destinatario ha activado esta opción y no ha añadido al remitente como contacto, relay_send se rechaza con verification_required.
Para iniciar una conversación, envíe un mensaje con "contact_request": true. Las solicitudes de contacto omiten la comprobación y están limitadas a 3 por remitente y destinatario dentro de una ventana móvil de 24 horas; las solicitudes adicionales se rechazan con contact_request_rate_limited. El indicador contact_request se entrega al destinatario para que la App pueda presentar el mensaje como una solicitud.
La verificación de contactos se aplica a relay_send y a la alternativa HTTPS.
Grupos
Mensajes de grupo
El contenido de grupo se cifra con una clave de grupo compartida de 32 bytes mediante AES-256-GCM:
payload_b64 = base64(nonce[12] || AES-256-GCM(group_key, plaintext_json) || tag[16])
El JSON en texto claro contiene text, type (text, media, system o reaction), sender_pubkey, created_at y, opcionalmente, attachments, mentions, reply, forwarded, forwarded_from_name, forwarded_from_pubkey y link_preview.
{
"type": "group_send",
"msg_id": "<32 hex>",
"group_id": "<uuid>",
"payload_b64": "<base64>",
"timestamp": 1780000000,
"payload_sig": "<128 hex>",
"key_version": 3
}
payload_sig = Ed25519(identity_key,
SHA256(UTF-8(group_id) || sender_pubkey[32] || 0x19 || timestamp as u64 little-endian
|| decoded payload))
El relé comprueba la firma y que el remitente sea un miembro activo, almacena el mensaje para cada uno de los demás miembros activos y después lo entrega en vivo. Las cargas útiles de grupo no usan el sobre 1:1, y los sobres de grupo se entregan con group_id, key_version y payload_sig para que los receptores puedan verificar al remitente antes de descifrar.
{
"type": "group_delivered",
"msg_id": "...",
"group_id": "...",
"accepted": true,
"accepted_count": 5,
"delivered_count": 2,
"queued_count": 5,
"failed_count": 0,
"member_count": 6
}
Un remitente que no es miembro recibe error con el motivo not_a_member.
Ediciones, revocaciones y reacciones en grupos
| Trama | Firma |
|---|---|
group_message_edit (group_id, msg_id, target_msg_id, payload_b64, payload_sig, key_version, timestamp) | Fórmula de grupo anterior. |
group_message_reaction (group_id, msg_id, reaction_id, payload_b64, payload_sig, key_version, timestamp) | Fórmula de grupo anterior. La carga útil es una carga útil de grupo con type: "reaction". |
group_message_revoke (group_id, sender_pubkey, msg_id, timestamp, payload_sig) | Ed25519 sin hash sobre `"aeronyx-group-message-revoke-v1" |
Cada una se confirma con la trama _ack correspondiente, que incluye los recuentos de entrega. Los receptores aplican ediciones y revocaciones solo si proceden del autor original.
Claves de grupo
El propietario del grupo distribuye las claves de grupo como paquetes de claves por miembro: base64(nonce[12] || AES-256-GCM(chat_key(owner, member), group_key) || tag[16]). Los endpoints REST bajo /api/relay/groups/ crean grupos, gestionan miembros e invitaciones, suben paquetes de claves, rotan claves (keys/rotate/) y obtienen el paquete actual del solicitante (keys/me/). Los remitentes cifran con la versión de clave más reciente que poseen; los receptores que no disponen de una versión de clave deben obtener keys/me/ y retener el mensaje hasta que llegue la clave.
Archivos adjuntos cifrados
Los archivos adjuntos se cifran en el dispositivo, se suben como texto cifrado opaco y se referencian desde el interior del mensaje cifrado.
Cifrar el archivo
Para cada archivo, genere una clave aleatoria de 32 bytes y un nonce aleatorio de 12 bytes:
blob = nonce[12] || AES-256-GCM(file_key, file_bytes) || tag[16]
Suba blob. Coloque file_key y todos los campos descriptivos dentro del mensaje cifrado, nunca en una solicitud de subida.
Objeto de archivo adjunto
| Clave | Obligatorio | Significado |
|---|---|---|
blob_id | sí | ID devuelto por la subida. |
file_key | sí | Base64 de la clave de archivo de 32 bytes. |
media_type | sí | Tipo MIME del archivo en texto claro. |
file_name | sí | Nombre para mostrar. |
file_size | sí | Tamaño en texto claro, en bytes. |
thumb_b64 | no | Miniatura JPEG en Base64, de hasta 64 KiB. |
duration_ms | no | Duración del audio o del vídeo. |
waveform | no | Hasta 96 números en [0, 1] para mensajes de voz. |
sticker, sticker_pack, sticker_pose | no | Identidad del sticker. |
live | no | Parte en movimiento de una Live Photo: {blob_id, file_key, media_type, file_size, duration_ms?, width?, height?}. |
Los receptores ignoran los archivos adjuntos que carecen de blob_id o file_key. Los mensajes de voz de la App son AAC-LC en un contenedor MP4 (audio/mp4).
Subida: solicitud única (hasta 10 MiB)
POST /api/relay/blob/presign/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: application/json
{ "file_size": 482141, "media_type": "image/jpeg", "media_kind": "image", "ttl_days": 7 }
file_size es el tamaño del texto cifrado. media_kind es uno de voice, image, video, file, avatar, other. ttl_days se limita al rango de 1 a 30 (7 por defecto).
{
"blob_id": "<uuid>",
"upload_url": "https://...",
"upload_method": "PUT",
"storage": "r2",
"expires_at": "2026-10-16T10:00:00Z",
"media_type": "image/jpeg",
"media_kind": "image",
"access_mode": "capability",
"ttl_days": 7,
"max_bytes": 10485760
}
A continuación:
- Envíe con
PUTel texto cifrado aupload_urlen un plazo de 15 minutos, solo con un encabezadoContent-Type. No envíe el encabezadoAuthorizational almacenamiento. - Llame a
POST /api/relay/blob/{blob_id}/complete/con RelayAuth. El relé confirma el objeto y devuelve{blob_id, file_size, expires_at, storage}.
Subida: multiparte (hasta 100 MiB)
POST /api/relay/blob/multipart/create/
{ "total_size": 52428800, "media_type": "video/mp4", "media_kind": "video", "ttl_days": 7 }
{
"blob_id": "<uuid>",
"upload_id": "...",
"part_size": 8388608,
"total_parts": 7,
"part_urls": ["https://...", "..."],
"storage": "r2",
"expires_at": "...",
"max_bytes": 104857600
}
part_size es de 8 MiB por defecto y puede solicitarse entre 5 y 16 MiB. Las URL de las partes son válidas durante 60 minutos. Envíe con PUT cada parte a su URL y registre el encabezado de respuesta ETag; a continuación:
POST /api/relay/blob/multipart/complete/
{ "blob_id": "<uuid>", "upload_id": "...", "parts": [ { "part_number": 1, "etag": "\"...\"" } ] }
La AeroNyx App usa la subida de solicitud única hasta 8 MiB de texto cifrado y la subida multiparte por encima de ese tamaño.
Descarga
GET /api/relay/blob/{blob_id}/
El relé responde 302 con un Location en la red de distribución de contenido y los encabezados X-AeroNyx-Blob-Size, X-AeroNyx-Blob-Media-Type y X-AeroNyx-Blob-Storage. Siga la redirección usted mismo sin reenviar ningún encabezado Authorization y, después, verifique y descifre el blob con su file_key. Limite la descarga al tamaño esperado.
Un blob_id es una capacidad al portador: cualquiera que lo posea puede obtener el texto cifrado, motivo por el cual la clave viaja únicamente dentro del mensaje cifrado. access_mode y la caducidad se aplican en la redirección del relé. No confíe en ellos para la confidencialidad.
Eliminación de archivos adjuntos
POST /api/relay/blob/{blob_id}/delete/
Solo quien subió un blob puede eliminarlo. La respuesta es {"blob_id": "...", "deleted": true}.
Errores de archivos adjuntos
Los errores usan {"success": false, "error": "<text>", "error_code": "<code>"}. Base la lógica en error_code.
| HTTP | error_code | Significado |
|---|---|---|
| 400 | blob_id_invalid | ID de blob mal formado. |
| 400 | blob_missing_file_size, blob_missing_total_size, blob_missing_parts, blob_bad_parts, blob_bad_part_count | Solicitud de subida no válida. |
| 400 | blob_not_r2, blob_multipart_complete_failed | La finalización falló; inicie una nueva subida. |
| 401 | auth_required | El blob requiere RelayAuth. |
| 403 | blob_not_uploader, download_forbidden | El solicitante no tiene permiso. |
| 404 | blob_not_found, blob_not_uploaded | Blob desconocido, o finalización antes de que terminara la subida. |
| 410 | blob_expired | El blob ha caducado. Pida al remitente que lo vuelva a enviar. |
| 413 | blob_too_large | Se superó el límite; la respuesta incluye max_bytes y chunked_max_bytes. |
| 503 | blob_r2_unavailable | Almacenamiento no disponible temporalmente; reintente con retroceso. |
Endpoints de subida heredados
POST /api/relay/blob/ (formulario multiparte, hasta 10 MiB) y la API de sesiones reanudables bajo /api/relay/blob/session/ (hasta 100 MiB, fragmentos de 64 KiB a 4 MiB, sesiones válidas durante 24 horas) siguen disponibles como alternativa. Los clientes nuevos deben usar los endpoints anteriores.
Notificaciones push
El relé envía notificaciones de Apple Push Notification service (APNs) para iOS y macOS. Los clientes de Android reciben los mensajes únicamente a través del WebSocket.
Se envía una notificación push para relay_send y group_send cuando el destinatario no tiene una conexión activa en primer plano, el remitente no estableció suppress_push y el destinatario no ha silenciado la conversación. Las ediciones, revocaciones, reacciones y confirmaciones no generan notificaciones push. Las cargas útiles push no contienen texto cifrado:
{
"aps": { "alert": { "title": "AeroNyx", "body": "New encrypted message" }, "sound": "default", "badge": 1, "content-available": 1 },
"kind": "p2p_message",
"sender_pubkey": "<64 hex>",
"message_id": "..."
}
kind es p2p_message (con sender_pubkey) o group_message (con group_id). Las notificaciones de llamadas usan missed_call y cargas útiles específicas de llamada. Al recibir una notificación push, conéctese y ejecute relay_pull.
| Endpoint | Cuerpo |
|---|---|
POST /api/relay/push/register/ | token (64 hex), platform (ios o macos), bundle_id, environment (production o sandbox), token_type opcional (alert o voip) y provider (apns). |
POST /api/relay/push/unregister/ | token, platform, token_type y provider opcionales. |
POST /api/relay/push/mute/ | kind (p2p o group), target (clave pública o ID de grupo), muted (booleano). |
Los tres requieren RelayAuth. Registrar un token lo asocia a la identidad que realiza la llamada.
Llamadas
La señalización de llamadas de voz y vídeo viaja por el mismo WebSocket como metadatos en texto claro; los medios fluyen por separado. Las tramas son call_invite, call_answer, call_reject, call_hangup y call_busy para llamadas 1:1, y group_call_invite, group_call_invite_broadcast, group_call_answer y group_call_hangup_broadcast para grupos, además de tramas de admisión para reuniones con anfitrión.
El relé valida room_name con respecto a los participantes: p2p_ seguido de los primeros 16 caracteres hexadecimales de SHA256(lower_key + ":" + higher_key) para llamadas 1:1, y grp_<first 8 characters of group_id>_<first 8 hex of SHA256(group_id)> para grupos. Las señales de llamada para un destinatario sin conexión se retienen durante 120 segundos.
Límites de frecuencia
| Ámbito | Límite | Respuesta |
|---|---|---|
relay_send, group_send y push HTTPS, por identidad | 50 por ventana corta; 100.000 al día | WebSocket: error rate_limited con retry_after. HTTPS: 429 con Retry-After. |
group_send por remitente y grupo | 10 por ventana corta | error rate_limited, scope: "sender". |
group_send por grupo | 50 por ventana corta | error rate_limited, scope: "group". |
| Reacciones por remitente y conversación | 20 por ventana corta | error rate_limited, scope: "reaction". |
| Solicitudes de contacto por remitente y destinatario | 3 cada 24 horas | send_rejected contact_request_rate_limited. |
Espere al menos retry_after segundos antes de reintentar. No reenvíe en un bucle cerrado: los contadores siguen corriendo mientras reintenta.
Lista de comprobación de implementación
- Genere y almacene una identidad Ed25519; use claves en hexadecimal en minúscula en todas partes.
- Implemente RelayAuth y el inicio de sesión, el latido y
presence_statedel WebSocket. - Implemente el sobre sellado y verifíquelo con el vector de referencia de Central Chat HTTPS API v1.
- Envíe con
relay_send, tratedurable: truecomo enviado y vuelva a generartimestampypayload_sigen los reintentos. - Reciba
relay_envelope: elimine duplicados, verifique, descifre y almacene; después envíemessage_receiptyrelay_offline_ack. - Ejecute
relay_pulltras el inicio de sesión y al despertar por una notificación push; confirme solo después del almacenamiento persistente. - Lea los indicadores de privacidad del perfil y respételos para el estado de conexión y las confirmaciones de lectura.
- Cifre los archivos adjuntos en el dispositivo y súbalos mediante
presigno multiparte; mantengafile_keydentro del mensaje cifrado. - Verifique la autoría antes de aplicar ediciones y revocaciones.
- Mantenga la búsqueda y el historial de mensajes en el dispositivo. El relé no ofrece búsqueda de contenido y no es un archivo.
Preguntas frecuentes
¿Puede AeroNyx Chat Relay leer mis mensajes?
No. Los mensajes, las ediciones, las reacciones, los mensajes de grupo y los archivos adjuntos se cifran y firman en el dispositivo del remitente antes de llegar al relé, y las claves nunca salen de los dispositivos de las personas que participan en la conversación. El relé solo almacena y reenvía texto cifrado.
¿Qué puede ver AeroNyx Chat Relay?
El relé ve metadatos de entrega: las claves públicas del remitente y del destinatario, los ID de grupo y de mensaje, las marcas de tiempo, los tamaños de las cargas útiles, el estado de entrega y de lectura, las señales de estado de conexión y de escritura, los metadatos de señalización de llamadas, los tamaños de los archivos adjuntos y las direcciones IP de conexión. No ve el contenido de los mensajes, el contenido de los archivos adjuntos ni las claves. La lista completa figura en el modelo de confianza, al principio de esta página.
¿Qué cifrado usa el chat de AeroNyx?
Cada identidad es un par de claves Ed25519. Dos personas derivan una clave de chat compartida con X25519 y HKDF-SHA256. Los mensajes 1:1 se cifran con XChaCha20-Poly1305 y se firman con Ed25519. Los mensajes de grupo y los archivos adjuntos se cifran con AES-256-GCM. Los formatos exactos de bytes y un vector de prueba se publican en la documentación de Central Chat HTTPS API v1.
¿Cómo se protegen las fotos, los vídeos y los archivos?
Cada archivo se cifra en el dispositivo con su propia clave AES-256-GCM aleatoria antes de subirse. El almacenamiento solo recibe texto cifrado. La clave del archivo viaja dentro del mensaje cifrado de extremo a extremo, por lo que solo los destinatarios pueden descifrar el archivo.
¿Qué ocurre si el destinatario no está conectado?
El relé conserva los mensajes cifrados en la cola sin conexión del destinatario durante un máximo de 72 horas y los entrega cuando el destinatario vuelve a conectarse. En iOS y macOS, el destinatario también recibe una notificación push que no contiene el contenido del mensaje.
¿Conserva AeroNyx mi historial de chat?
No. El relé es un búfer de entrega: los elementos se eliminan después de que el dispositivo del destinatario confirma que los ha almacenado. El historial de chat y la búsqueda residen en sus dispositivos.
¿Puedo crear mi propio cliente o bot de AeroNyx?
Sí. Cualquier software que disponga de una identidad Ed25519 e implemente los formatos de esta página puede intercambiar mensajes con usuarios de la AeroNyx App. Para una integración más sencilla de solicitud/respuesta sin WebSocket, use Central Chat HTTPS API v1.
¿Es descentralizado AeroNyx Chat Relay?
Chat Relay es el servicio de entrega centralizado de AeroNyx. AeroNyx también opera una red de nodos de código abierto (AGPL-3.0) que puede transportar el texto cifrado del chat por una ruta de dos saltos con diversidad de red. Ambas rutas coexisten: el relé ofrece una entrega rápida y fiable y colas sin conexión, y la ruta de nodos es una ruta opcional que reduce lo que puede observar cualquier operador individual.
¿Por qué se rechaza mi mensaje con verification_required?
El destinatario solo acepta mensajes de sus contactos. Envíe un único primer mensaje con contact_request: true, que el destinatario verá como una solicitud de contacto. Se permiten hasta tres solicitudes de contacto por destinatario en cualquier período de 24 horas.
Entrega verificada en dos saltos
Para el tráfico ChatRelay autenticado que cumpla los requisitos, el origen puede elegir una ruta de dos saltos con diversidad de red y contabilizar la entrega solo después de validar la confirmación firmada del nodo terminal esperado. Los nodos de retransmisión enrutan texto cifrado y no analizan la carga útil E2E. Consulte el modelo de evidencia completo en Descubrimiento de nodos y entrega cifrada verificada a través de relés.
<!-- verified-two-hop-delivery-v1:end -->