AeroNyx 聊天中继客户端集成

AeroNyx20 分钟阅读

AeroNyx 聊天中继集成参考:WebSocket 认证、密封的一对一与群组消息、离线投递、回执、表情回应、在线状态、加密附件、推送通知与速率限制。

生成接入提示词

选择技术栈和接入目标,即可复制给编程助手。提示词在本地生成,不会发送到 AI 服务。

生成的提示词

本文是 AeroNyx 聊天中继(Chat Relay)的集成参考。聊天中继是部署在 api.aeronyx.network 上的 WebSocket 与 HTTPS 服务,负责在 AeroNyx 身份之间传递端到端加密的一对一消息、群组消息、回执、表情回应、在线状态以及加密附件。

本文面向构建 AeroNyx 兼容客户端、机器人和服务的工程师,以及负责实现这些功能的 AI 编码智能体。本页中的每一个帧、字段和限制,均与截至 2026 年 10 月的生产环境中继及 AeroNyx App 保持一致。

聊天中继是中心化的投递路径。它与去中心化的节点路径(洋葱路由与匿名邮箱,参见已验证的两跳投递)并存,客户端可以同时使用两者。如需不依赖 WebSocket 的请求/响应式 HTTPS 集成,请参阅中心化聊天 HTTPS API v1。

信任模型

中继无法看到任何内容。客户端在数据到达中继之前完成全部加密与签名,中继只负责对不透明的密文进行路由、排队和速率限制。

中继永远不会接收到:

  • 明文形式的消息文本、表情回应 emoji、编辑内容或群组载荷
  • 聊天密钥、群组密钥、附件密钥或 nonce
  • 附件内容、文件名、缩略图、波形或转写文本
  • 身份私钥

中继能够观察到投递元数据,集成方应将这些信息视为对运营方可见:

  • 发送方与接收方的公钥、群组 ID 和消息 ID
  • 时间戳、载荷大小,以及投递、回执和已读状态
  • 在线状态、前台状态和正在输入提示(以明文帧发送)
  • 通话信令元数据(房间名、通话 ID、视频标志)
  • 附件密文大小、声明的媒体类型和过期时间
  • contact_request 标志以及 IP 层面的连接元数据

内容的机密性来自端到端加密,而不是中继上的访问控制。请据此进行设计:即使攻击者获取了存储的密文,也必须仍然无法读取其内容。

身份与密钥

AeroNyx 聊天身份是一个 Ed25519 密钥对。32 字节的公钥以 64 个小写十六进制字符表示,即为地址。请在设备上生成密钥,且绝不要将私钥发送到任何地方。

由身份密钥对派生出两种密钥:

  • 聊天密钥(一对一)。 HKDF-SHA256(salt = empty, ikm = X25519(my_secret, peer_public), info = "AERONYX-P2P-KEY", length = 32),其中两个 Ed25519 密钥均需转换为 X25519(私钥取 SHA-512(seed)[0..32] 并进行钳位处理,公钥采用 Edwards 曲线到 Montgomery 曲线的转换)。通信双方会派生出相同的密钥。
  • 消息签名。 使用身份密钥进行 Ed25519 签名,签名对象为本页定义的精确字节串。

帧中的公钥一律使用小写十六进制。中继并不会在每个队列键中都对大小写进行规范化。

认证

RelayAuth 签名

WebSocket 登录以及所有需要认证的 HTTPS 端点均使用同一种签名:

text
digest    = SHA256("AeroNyx-RelayAuth-v1" || pubkey[32] || timestamp as u64 little-endian)
signature = Ed25519(identity_key, digest)

"AeroNyx-RelayAuth-v1" 为 20 个 ASCII 字节,不含终止符。timestamp 为以秒为单位的 Unix 时间戳,与服务器时间的偏差必须在 300 秒以内。

该签名只绑定身份与时间,不绑定请求方法、路径、请求体或连接,也不包含 nonce。请为每个请求生成新的时间戳,仅通过 TLS 发送,且绝不要将其写入日志。

HTTPS 请求头

http
Authorization: Relay <pubkey-hex>:<timestamp>:<signature-hex>

校验失败时返回 HTTP 401 及 {"success": false, "error": "<reason>"}。原因包括 missing_auth_header、malformed_auth_header、invalid_timestamp、timestamp_expired、invalid_pubkey 和 invalid_signature。

WebSocket 连接

端点

text
wss://api.aeronyx.network/ws/relay/

原生客户端连接时不携带 Origin 请求头。浏览器必须从允许的来源发起连接;任何其他来源或未知的 Host 都会以代码 1008 关闭连接。

登录

服务器接受套接字后,要求在 30 秒内收到 auth 帧:

json
{
  "type": "auth",
  "pubkey": "<64 hex>",
  "timestamp": 1780000000,
  "signature": "<128 hex>"
}

成功:

json
{ "type": "auth_ack", "session_id": "<uuid>", "server_ts": 1780000001 }

请使用 server_ts 估算时钟偏差;消息时间戳同样按 ±300 秒的窗口进行校验。

收到 auth_ack 后,服务器会启动心跳,为该连接订阅相应频道,并立即重放离线队列(参见离线投递)。

失败时服务器发送 {"type": "auth_error", "reason": "<reason>"},并以代码 4001 关闭套接字。原因包括:missing_fields、timestamp_expired、invalid_pubkey、invalid_signature_length、invalid_signature_encoding、invalid_signature、internal_error。登录超时时发送原因 timeout,并以 4002 关闭连接。

登录前收到的任何其他帧,服务器均以原因为 authentication_required 的 auth_error 响应,套接字保持打开。

心跳与前台状态

方向帧行为
服务器 → 客户端{"type":"ping"},每 30 秒一次回复 {"type":"pong"}。
客户端 → 服务器{"type":"ping"}服务器回复 {"type":"pong"}。
客户端 → 服务器{"type":"presence_state","foreground":true}将此连接标记为活跃。
客户端 → 服务器{"type":"presence_state","foreground":false}将此连接标记为后台状态,以便中继在有新消息时发送推送。

仅当某身份的连接至少每 90 秒发送一次 ping、pong 或 foreground: true 的 presence_state 时,中继才会将该身份视为在线。AeroNyx App 在前台时每 15 秒发送一次 ping,连续丢失超过三个 pong 即视为连接已失效。

长连接应至少每 24 小时重新连接一次。即使连接在无提示的情况下停止接收实时帧,消息也不会丢失,因为每条消息同时会进入队列并在登录时重放;但实时投递只有在重新连接后才会恢复。

帧规则

  • 仅支持文本帧,每帧一个 JSON 对象。二进制帧会被忽略。
  • 帧大小上限为 1,048,576 个字符。超出上限的帧会被拒绝,并返回 {"type":"error","reason":"message_too_large"}。请将 payload_b64 控制在约 800 KiB 以内,为帧的其余部分预留空间。
  • 格式错误的帧会返回 error,原因为 invalid_json、invalid_json_type 或 unknown_type。

通用错误帧:

json
{ "type": "error", "reason": "<reason>", "retry_after": 0 }

速率限制错误还会携带 scope。

关闭代码

代码含义
1008Host 或 Origin 不被允许。
4000服务器无法发送心跳。
4001登录失败。
4002登录超时。

请使用带随机抖动的指数退避进行重连。AeroNyx App 的等待时间为 2^(attempt-1) 秒,限制在 1–60 秒之间,再乘以 0.8 到 1.2 之间的随机系数。

发送一对一消息

1. 构建密封信封

一对一消息的载荷是经过签名和加密的 ChatEnvelope。其精确的构造方式、Python 参考实现以及黄金测试向量,参见中心化聊天 HTTPS API v1:密封信封格式。两套 API 使用同一种信封。

概括而言:使用聊天密钥进行 XChaCha20-Poly1305 加密,对 121 字节的签名原文进行 Ed25519 签名,并采用固定的二进制布局。所有消息(包括带附件的消息)的 content_type 均为 0。

对于普通消息,明文为 UTF-8 文本;对于带附件、回复、转发或链接预览的消息,明文为一个 JSON 对象:

json
{
  "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": "..." }
}

除 type 和 text 外,其余字段均为可选。凡是不属于 "type": "aeronyx_message" JSON 对象的明文,接收方都应将其作为纯文本呈现。附件对象的定义参见加密附件。

2. 为帧签名

每个消息帧都携带第二个签名 payload_sig,中继在接受该帧之前会先对其进行验证:

text
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 是解码后的 payload_b64。消息与编辑使用判别值 11,表情回应使用判别值 12。payload_sig 采用十六进制编码。

3. 发送

json
{
  "type": "relay_send",
  "msg_id": "9e762ae0f5da43e6bec5eb8143f1f834",
  "receiver_pubkey": "<64 hex>",
  "discriminant": 11,
  "payload_b64": "<base64 envelope>",
  "timestamp": 1780000000,
  "payload_sig": "<128 hex>"
}
字段规则
msg_id32 个小写十六进制字符,即信封中 16 字节的 message_id。接收端 App 以此 ID 存储消息,因此必须与信封保持一致。
receiver_pubkey接收方的公钥。
discriminant11。
timestamp以秒为单位的 Unix 时间戳,与服务器时间的偏差在 ±300 秒以内。
suppress_push可选。为 true 时不发送推送通知。
contact_request可选。用于标记发给要求联系人验证的用户的首条消息(参见联系人验证)。

4. 确认

json
{ "type": "relay_delivered", "msg_id": "...", "delivered": true, "queued": true, "durable": true }
  • 消息存入接收方的离线队列后,durable(以及 queued)即为 true。请将 durable: true 视为“已发送”。
  • delivered 表示接收方当时有活跃的前台连接。它并不能证明对方已收到消息;如需确认,请使用投递回执。

请最多等待 15 秒以接收确认,超时后再将本次尝试视为失败。

拒绝与错误

json
{ "type": "send_rejected", "msg_id": "...", "reason": "verification_required" }
响应原因处理方式
send_rejectedverification_required接收方只接受联系人发送的消息。请勿重试。
send_rejectedcontact_request_rate_limited针对该接收方的联系请求已达上限。请勿重试。
errormissing_fields缺少必填字段,或字段为空。
errorinvalid_timestamp,timestamp_expired校正时钟后重新生成时间戳。
errorinvalid_payload_sigpayload_sig 验证失败。
errorrate_limited在 retry_after 秒后重试。

重试

对于未获确认的消息,请使用相同的 msg_id 和相同的信封字节进行重试。由于中继会拒绝超过 300 秒的帧,每次重试都需要重新计算帧的 timestamp 和 payload_sig;信封本身保留其原始时间戳。中继和接收方均按 msg_id 进行去重。

HTTPS 后备通道

当 WebSocket 不可用时,可以通过 HTTPS 提交同一条消息:

http
POST /api/relay/push/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: application/json
json
{
  "receiver_pubkey": "<64 hex>",
  "discriminant": 11,
  "payload_b64": "<base64 envelope>",
  "timestamp": 1780000000,
  "payload_sig": "<128 hex>"
}

成功时返回 HTTP 200 及 {"success": true}。联系人验证失败时返回 400,并附带 verification_required 或 contact_request_rate_limited;触发速率限制时返回 429,并附带 Retry-After。通过这种方式发送的消息会进入接收方的队列,但不会触发推送通知,因此请在 WebSocket 重新连接后通过 WebSocket 再次发送。

接收消息

传入的消息以 relay_envelope 的形式到达:

json
{
  "type": "relay_envelope",
  "sender_pubkey": "<64 hex>",
  "discriminant": 11,
  "payload_b64": "<base64 envelope>",
  "timestamp": 1780000000,
  "msg_id": "9e762ae0f5da43e6bec5eb8143f1f834",
  "from_offline": false
}

对于 discriminant: 11:

  1. 按 msg_id 去重。同一条消息可能先实时到达,随后又从离线队列再次到达。
  2. 解析信封,并检查其 receiver_pubkey 是否为你的身份。
  3. 对于实时帧(from_offline: false),如果信封时间戳与本地时钟相差超过 300 秒,则丢弃该消息。
  4. 使用信封中的 sender_pubkey 验证信封签名。经过认证的发送方是该密钥,而不是帧中的 sender_pubkey。
  5. 使用由该发送方派生的聊天密钥进行解密。非常旧的 App 版本直接使用原始 X25519 输出进行加密;如果使用 HKDF 密钥解密失败,可尝试该方式。
  6. 将消息持久化存储,然后发送投递回执;对于 from_offline: true 的消息,还需发送离线确认。

一对一信封在投递时不携带 payload_sig,信封签名本身即为真实性校验。帧中也可能携带 contact_request: true。

如果消息并非发给你、验证失败或解密失败,请直接丢弃,不显示任何内容。

离线投递

每条消息、编辑、撤回、表情回应、回执和已读回执在实时投递之前,都会先写入接收方的离线队列。每次登录后队列会自动重放,也可以按需请求重放:

json
{ "type": "relay_pull" }

中继会按时间戳顺序,以各条目原本的帧类型并附带 from_offline: true 重放所有排队条目,随后发送:

json
{ "type": "relay_pull_done", "count": 12, "has_more": false }

投递语义为至少一次。各条目在得到确认之前会一直保留在队列中:

json
{ "type": "relay_offline_ack", "msg_id": "9e762ae0f5da43e6bec5eb8143f1f834" }

确认表情回应时,请使用 "reaction_id" 而非 "msg_id"。请仅在条目已持久化存储到设备后再进行确认。中继会回复 {"type":"relay_offline_ack","msg_id":"...","success":true}。

队列限制:

限制项值
每个接收方的条目数1,000。队列已满时,新条目会被拒绝,发送方会看到 durable: false。
保留期限自最近一个条目加入起 72 小时。
载荷大小解码后 1 MiB,且不超过 1 MiB 的帧大小限制。

中继是投递缓冲区,而不是消息历史记录。请将历史记录保存在设备上。

投递回执与已读回执

投递回执

存储消息后发送:

json
{ "type": "message_receipt", "receiver_pubkey": "<original sender>", "msg_id": "...", "timestamp": 1780000010 }

中继会回复 message_receipt_ack,并向原发送方投递 {"type":"message_receipt","sender_pubkey":"...","msg_id":"...","timestamp":...}。

已读回执

json
{ "type": "message_read", "receiver_pubkey": "<original sender>", "msg_id": "<latest read message>", "timestamp": 1780000200, "enabled": true }

已读回执采用水位线机制:App 会将指定消息以及该会话中所有更早的外发消息标记为已读。仅当用户启用了已读回执时才发送。

中继在发送、实时投递和重放三个环节均执行对等规则:只有当双方互为联系人,且都启用了 read_receipts_enabled 时,已读回执才会被投递。否则,发送方会收到:

json
{ "type": "message_read_ack", "msg_id": "...", "delivered": false, "suppressed": true, "reason": "receiver_read_receipts_disabled" }
原因含义
client_disabled帧中携带了 enabled: false 或 read_receipts_enabled: false。
not_mutual_contact双方并非互为联系人。
reader_read_receipts_disabled阅读方已关闭已读回执。
receiver_read_receipts_disabled原发送方已关闭已读回执。
invalid_pubkey某个公钥格式错误。

已投递的已读回执会以 {"type":"message_read_ack","msg_id":"...","delivered":true} 进行确认。

编辑与撤回

编辑

编辑是一个新的密封信封(拥有自己的随机 message_id),其中包含完整的替换内容,并针对原消息 ID 发送:

json
{
  "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 使用为帧签名一节中的公式,判别值为 11。移除全部附件的编辑会在其明文 JSON 中设置 "attachments_edited": true。中继会回复 message_edit_ack。接收方应像处理普通消息一样验证并解密该信封,并且只有当编辑的发送方正是原消息的作者时,才可应用该编辑。

撤回

json
{
  "type": "message_revoke",
  "receiver_pubkey": "<64 hex>",
  "sender_pubkey": "<64 hex>",
  "msg_id": "<original msg_id>",
  "timestamp": 1780000500,
  "payload_sig": "<128 hex>"
}

撤回签名直接作用于原始字节,不经过哈希:

text
payload_sig = Ed25519(identity_key,
    "aeronyx-message-revoke-v1" || sender_pubkey[32] || receiver_pubkey[32]
    || UTF-8(msg_id) || timestamp as u64 little-endian)

中继会回复 message_revoke_ack。中继不检查作者身份:接收方必须验证签名,并且只有当撤回的发送方正是原消息的作者时,才可应用该撤回。如需删除已撤回消息的附件,请调用 POST /api/relay/blob/{blob_id}/delete/。

表情回应

一对一表情回应是一个密封信封,其 message_id 为回应 ID,content_type 为 2,明文为 {"emoji": "❤️", "op": "add"}(或 "remove"):

json
{
  "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_sig 使用判别值 12。
  • reaction_id 是幂等键。72 小时内重复的 reaction_id 会以 "duplicate": true 予以确认,但不会再次投递。
  • 中继会回复 {"type":"message_reaction_ack","msg_id":"...","reaction_id":"...","delivered":...,"queued":...}。
  • 在速率窗口内,每个发送方在每个会话中最多可发送 20 个表情回应。

群组表情回应在群组一节中说明。

在线状态与正在输入

在线状态与正在输入帧属于明文元数据,对中继可见。

在线状态

订阅联系人:

json
{ "type": "presence_subscribe", "pubkeys": ["<64 hex>", "<64 hex>"] }

每帧最多处理 200 个公钥;格式错误和重复的公钥会被忽略。订阅会在连接的整个生命周期内持续累积。请只订阅你自己的联系人。

json
{
  "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
}

在线状态仅在互为联系人的双方之间可见,且前提是目标用户启用了 presence_enabled。被隐藏的条目(reason 为 not_mutual_contact 或 presence_hidden)不包含 online 或 last_seen_ts。只有当 last_seen_visible 为 true 时才会包含 last_seen_ts;否则请显示通用状态,例如“最近在线”。

实时变化以如下形式到达:

json
{ "type": "presence_update", "pubkey": "<a>", "online": true, "presence_visible": true, "last_seen_visible": true, "last_seen_ts": 1780000100 }

正在输入

json
{ "type": "typing", "receiver_pubkey": "<64 hex>", "is_typing": true }
{ "type": "group_typing", "group_id": "<uuid>", "is_typing": true }

仅当发送方的在线状态对接收方可见(互为联系人且启用了 presence_enabled),或接收方是发送方所在群组的其他成员时,正在输入提示才会被转发。它永远不会被存储、排队或推送。

个人资料与隐私设置

http
GET /api/relay/profile/
Authorization: Relay <pubkey>:<timestamp>:<signature>
json
{
  "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"
  }
}

尚未设置个人资料的身份会收到空字段,且三个隐私标志均为 true。

http
PATCH /api/relay/profile/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: application/json
json
{ "display_name": "Alice", "privacy": { "presence_enabled": true, "last_seen_enabled": false, "read_receipts_enabled": false } }
字段规则
display_name最多 50 个字符。
bio最多 200 个字符。
avatar_urlhttps:// URL 或空值。
handle仅限 a-z 和 0-9,长度 5–24 个字符。用户名受会员规则和修改冷却期约束。
privacy.*布尔值。这三个标志也可以放在顶层发送。

错误包括 no_valid_fields、<flag>_invalid_boolean、handle_taken 和 handle_change_cooldown:<date>。

请确保客户端行为与这些设置保持一致:已读回执关闭时不要发送已读回执;自己的已读回执关闭时,不要显示对方的已读状态。

联系人验证

用户可以要求陌生人在发送消息前先通过验证。当接收方开启此功能且尚未将发送方添加为联系人时,relay_send 会以 verification_required 被拒绝。

如需发起对话,请发送一条带有 "contact_request": true 的消息。联系请求可以绕过该检查,但在滚动的 24 小时窗口内,每对发送方与接收方之间最多 3 次;超出的请求会以 contact_request_rate_limited 被拒绝。contact_request 标志会一并投递给接收方,以便 App 将该消息作为联系请求呈现。

联系人验证适用于 relay_send 和 HTTPS 后备通道。

群组

群组消息

群组内容使用共享的 32 字节群组密钥,以 AES-256-GCM 进行加密:

text
payload_b64 = base64(nonce[12] || AES-256-GCM(group_key, plaintext_json) || tag[16])

明文 JSON 包含 text、type(text、media、system 或 reaction)、sender_pubkey、created_at,以及可选的 attachments、mentions、reply、forwarded、forwarded_from_name、forwarded_from_pubkey 和 link_preview。

json
{
  "type": "group_send",
  "msg_id": "<32 hex>",
  "group_id": "<uuid>",
  "payload_b64": "<base64>",
  "timestamp": 1780000000,
  "payload_sig": "<128 hex>",
  "key_version": 3
}
text
payload_sig = Ed25519(identity_key,
    SHA256(UTF-8(group_id) || sender_pubkey[32] || 0x19 || timestamp as u64 little-endian
           || decoded payload))

中继会校验签名并确认发送方为活跃成员,为其他每位活跃成员存储该消息,然后进行实时投递。群组载荷不使用一对一信封;群组信封在投递时会携带 group_id、key_version 和 payload_sig,以便接收方在解密前验证发送方。

json
{
  "type": "group_delivered",
  "msg_id": "...",
  "group_id": "...",
  "accepted": true,
  "accepted_count": 5,
  "delivered_count": 2,
  "queued_count": 5,
  "failed_count": 0,
  "member_count": 6
}

非成员发送方会收到原因为 not_a_member 的 error。

群组编辑、撤回与表情回应

帧签名
group_message_edit (group_id,msg_id,target_msg_id,payload_b64,payload_sig,key_version,timestamp)使用上述群组签名公式。
group_message_reaction (group_id,msg_id,reaction_id,payload_b64,payload_sig,key_version,timestamp)使用上述群组签名公式。载荷为 type: "reaction" 的群组载荷。
group_message_revoke (group_id,sender_pubkey,msg_id,timestamp,payload_sig)对 `"aeronyx-group-message-revoke-v1"

上述每种帧都会以对应的 _ack 帧进行确认,其中携带投递计数。接收方只应用原作者发出的编辑和撤回。

群组密钥

群组密钥由群主以逐成员密钥包的形式分发:base64(nonce[12] || AES-256-GCM(chat_key(owner, member), group_key) || tag[16])。/api/relay/groups/ 下的 REST 端点用于创建群组、管理成员与邀请、上传密钥包、轮换密钥(keys/rotate/)以及获取调用方当前的密钥包(keys/me/)。发送方使用自己持有的最新密钥版本进行加密;缺少某个密钥版本的接收方应请求 keys/me/,并暂存该消息,直到获得对应密钥。

加密附件

附件在设备上加密,以不透明密文的形式上传,并在加密消息内部进行引用。

加密文件

为每个文件生成一个随机的 32 字节密钥和一个随机的 12 字节 nonce:

text
blob = nonce[12] || AES-256-GCM(file_key, file_bytes) || tag[16]

上传 blob。将 file_key 及所有描述性字段放入加密消息中,绝不要放在上传请求里。

附件对象

键必填含义
blob_id是上传时返回的 ID。
file_key是32 字节文件密钥的 Base64 编码。
media_type是明文文件的 MIME 类型。
file_name是显示名称。
file_size是明文大小(字节)。
thumb_b64否Base64 编码的 JPEG 缩略图,最大 64 KiB。
duration_ms否音频或视频时长。
waveform否语音消息的波形数据,最多 96 个取值在 [0, 1] 内的数字。
sticker,sticker_pack,sticker_pose否贴纸标识。
live否Live Photo 的动态部分:{blob_id, file_key, media_type, file_size, duration_ms?, width?, height?}。

接收方会忽略缺少 blob_id 或 file_key 的附件。App 发出的语音消息为 MP4 容器封装的 AAC-LC 音频(audio/mp4)。

上传:单次请求(最大 10 MiB)

http
POST /api/relay/blob/presign/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: application/json
json
{ "file_size": 482141, "media_type": "image/jpeg", "media_kind": "image", "ttl_days": 7 }

file_size 为密文大小。media_kind 取值为 voice、image、video、file、avatar、other 之一。ttl_days 限制在 1–30 之间(默认为 7)。

json
{
  "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
}

随后:

  1. 在 15 分钟内将密文通过 PUT 上传到 upload_url,仅携带 Content-Type 请求头。不要将 Authorization 请求头发送给存储服务。
  2. 携带 RelayAuth 调用 POST /api/relay/blob/{blob_id}/complete/。中继确认对象后返回 {blob_id, file_size, expires_at, storage}。

上传:分片上传(最大 100 MiB)

http
POST /api/relay/blob/multipart/create/
json
{ "total_size": 52428800, "media_type": "video/mp4", "media_kind": "video", "ttl_days": 7 }
json
{
  "blob_id": "<uuid>",
  "upload_id": "...",
  "part_size": 8388608,
  "total_parts": 7,
  "part_urls": ["https://...", "..."],
  "storage": "r2",
  "expires_at": "...",
  "max_bytes": 104857600
}

part_size 默认为 8 MiB,也可以请求 5 到 16 MiB 之间的值。分片 URL 的有效期为 60 分钟。将每个分片通过 PUT 上传到对应的 URL,并记录 ETag 响应头,然后:

http
POST /api/relay/blob/multipart/complete/
json
{ "blob_id": "<uuid>", "upload_id": "...", "parts": [ { "part_number": 1, "etag": "\"...\"" } ] }

AeroNyx App 在密文不超过 8 MiB 时使用单次请求上传,超过 8 MiB 时使用分片上传。

下载

http
GET /api/relay/blob/{blob_id}/

中继以 302 响应,其中 Location 指向内容分发网络,并附带 X-AeroNyx-Blob-Size、X-AeroNyx-Blob-Media-Type 和 X-AeroNyx-Blob-Storage 响应头。请由客户端自行跟随重定向,且不要转发任何 Authorization 请求头,然后使用对应的 file_key 验证并解密该 blob。下载量应以预期大小为上限。

blob_id 是一种持有者能力凭证:任何持有它的人都可以获取密文,这正是密钥只在加密消息内部传递的原因。access_mode 和过期时间在中继重定向环节强制执行,但不要依赖它们来保障机密性。

删除附件

http
POST /api/relay/blob/{blob_id}/delete/

只有上传者可以删除 blob。响应为 {"blob_id": "...", "deleted": true}。

附件错误

错误采用 {"success": false, "error": "<text>", "error_code": "<code>"} 格式。请根据 error_code 进行分支处理。

HTTPerror_code含义
400blob_id_invalidblob ID 格式错误。
400blob_missing_file_size,blob_missing_total_size,blob_missing_parts,blob_bad_parts,blob_bad_part_count上传请求无效。
400blob_not_r2,blob_multipart_complete_failed完成上传失败;请重新发起上传。
401auth_required该 blob 需要 RelayAuth 认证。
403blob_not_uploader,download_forbidden调用方无权执行此操作。
404blob_not_found,blob_not_uploadedblob 不存在,或在上传尚未完成时调用了完成接口。
410blob_expiredblob 已过期。请让发送方重新发送。
413blob_too_large超出大小限制;响应中包含 max_bytes 和 chunked_max_bytes。
503blob_r2_unavailable存储服务暂时不可用;请以退避方式重试。

旧版上传端点

POST /api/relay/blob/(multipart 表单,最大 10 MiB)以及 /api/relay/blob/session/ 下的可续传会话 API(最大 100 MiB,分块大小为 64 KiB 至 4 MiB,会话有效期 24 小时)仍作为后备方案保留。新客户端应使用上述端点。

推送通知

中继通过 Apple 推送通知服务(APNs)向 iOS 和 macOS 发送通知。Android 客户端仅通过 WebSocket 接收消息。

当接收方没有活跃的前台连接、发送方未设置 suppress_push,且接收方未将该会话设为免打扰时,relay_send 和 group_send 会触发推送。编辑、撤回、表情回应和回执不会触发推送。推送载荷不包含任何密文:

json
{
  "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 为 p2p_message(附带 sender_pubkey)或 group_message(附带 group_id)。通话通知使用 missed_call 以及专用的通话载荷。收到推送后,请建立连接并执行 relay_pull。

端点请求体
POST /api/relay/push/register/token(64 位十六进制)、platform(ios 或 macos)、bundle_id、environment(production 或 sandbox),以及可选的 token_type(alert 或 voip)和 provider(apns)。
POST /api/relay/push/unregister/token,以及可选的 platform、token_type、provider。
POST /api/relay/push/mute/kind(p2p 或 group)、target(公钥或群组 ID)、muted(布尔值)。

以上三个端点均需要 RelayAuth 认证。注册令牌时,该令牌会被转移到调用方身份名下。

通话

语音和视频通话的信令以明文元数据的形式经由同一个 WebSocket 传输,媒体流则单独传输。一对一通话使用的帧为 call_invite、call_answer、call_reject、call_hangup 和 call_busy;群组通话使用 group_call_invite、group_call_invite_broadcast、group_call_answer 和 group_call_hangup_broadcast;主持会议另有会议准入帧。

中继会根据参与者校验 room_name:一对一通话为 p2p_ 后接 SHA256(lower_key + ":" + higher_key) 的前 16 个十六进制字符;群组通话为 grp_<first 8 characters of group_id>_<first 8 hex of SHA256(group_id)>。发往离线接收方的通话信令会保留 120 秒。

速率限制

范围限制响应
relay_send、group_send 及 HTTPS 推送接口,按身份计每个短窗口 50 次;每天 100,000 次WebSocket:error rate_limited,附带 retry_after。HTTPS:429,附带 Retry-After。
group_send,按发送方与群组计每个短窗口 10 次error rate_limited,scope: "sender"。
group_send,按群组计每个短窗口 50 次error rate_limited,scope: "group"。
表情回应,按发送方与会话计每个短窗口 20 次error rate_limited,scope: "reaction"。
联系请求,按发送方与接收方计每 24 小时 3 次send_rejected contact_request_rate_limited。

请至少退避 retry_after 秒。不要在紧密循环中重复发送:重试期间计数器仍会持续累计。

实施清单

  1. 生成并存储 Ed25519 身份;所有场景均使用小写十六进制公钥。
  2. 实现 RelayAuth,以及 WebSocket 登录、心跳和 presence_state。
  3. 实现密封信封,并使用中心化聊天 HTTPS API v1中的黄金向量进行验证。
  4. 使用 relay_send 发送,将 durable: true 视为已发送,重试时重新生成 timestamp 和 payload_sig。
  5. 接收 relay_envelope:去重、验证、解密、存储,然后发送 message_receipt 和 relay_offline_ack。
  6. 在登录后及被推送唤醒时执行 relay_pull;仅在持久化存储完成后再进行确认。
  7. 读取个人资料中的隐私标志,并在在线状态和已读回执上遵循这些设置。
  8. 在设备上加密附件,并通过 presign 或分片上传进行上传;将 file_key 保留在加密消息内部。
  9. 在应用编辑和撤回之前验证作者身份。
  10. 在设备上保存消息搜索索引与历史记录。中继不提供内容搜索,也不是归档服务。
<!-- faq:start -->

常见问题

AeroNyx 聊天中继能读取我的消息吗?

不能。消息、编辑、表情回应、群组消息和附件在到达中继之前,就已在发送方的设备上完成加密和签名,并且密钥从不离开对话参与者的设备。中继只存储和转发密文。

AeroNyx 聊天中继能看到什么?

中继能看到投递元数据:发送方与接收方的公钥、群组 ID 和消息 ID、时间戳、载荷大小、投递和已读状态、在线状态和正在输入信号、通话信令元数据、附件大小以及连接的 IP 地址。它看不到消息内容、附件内容或密钥。完整列表见本页顶部的信任模型。

AeroNyx 聊天使用哪种加密?

每个身份都是一个 Ed25519 密钥对。双方通过 X25519 和 HKDF-SHA256 派生出共享的聊天密钥。一对一消息使用 XChaCha20-Poly1305 加密,并使用 Ed25519 签名。群组消息和附件使用 AES-256-GCM 加密。确切的字节格式和测试向量已发布在中心化聊天 HTTPS API v1 文档中。

照片、视频和文件如何受到保护?

每个文件在上传之前,都会在设备上使用其专属的随机 AES-256-GCM 密钥加密。存储服务只接收密文。文件密钥在端到端加密的消息内传递,因此只有接收方才能解密该文件。

如果接收方离线会怎样?

中继会将加密消息保存在接收方的离线队列中,最长 72 小时,并在接收方重新连接时投递。在 iOS 和 macOS 上,接收方还会收到一条不含消息内容的推送通知。

AeroNyx 会保存我的聊天记录吗?

不会。中继只是投递缓冲区:接收方设备确认已存储条目后,这些条目即被删除。聊天记录和搜索都保存在您的设备上。

我可以构建自己的 AeroNyx 客户端或机器人吗?

可以。任何持有 Ed25519 身份并实现本页所述格式的软件,都可以与 AeroNyx App 用户交换消息。如需不使用 WebSocket、更简单的请求/响应式集成,请使用中心化聊天 HTTPS API v1。

AeroNyx 聊天中继是去中心化的吗?

聊天中继是 AeroNyx 的中心化投递服务。AeroNyx 还运营一个开源(AGPL-3.0)节点网络,可以通过网络多样化的两跳路由传送聊天密文。两条路径并存:中继提供快速、可靠的投递和离线队列,而节点路径是一条可选路由,可减少任何单一运营方能够观察到的信息。

为什么我的消息因 verification_required 被拒绝?

接收方只接受来自联系人的消息。请发送一条带有 contact_request: true 的首条消息,接收方会将其视为联系人请求。在任意 24 小时窗口内,每个接收方最多允许三次联系人请求。

<!-- faq:end --> <!-- verified-two-hop-delivery-v1:start -->

已验证的两跳投递

对于符合条件且经过认证的 ChatRelay 流量,源端可以选择具备网络多样性的两跳路径,并且只有在验证了预期终端节点的签名回执之后,才将其计为投递成功。中继节点只负责路由密文,不解析端到端加密载荷。完整的证据模型请参阅节点发现与已验证的加密中继投递。

<!-- verified-two-hop-delivery-v1:end -->