AeroNyx 聊天中继客户端集成
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 端点均使用同一种签名:
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 请求头
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 连接
端点
wss://api.aeronyx.network/ws/relay/
原生客户端连接时不携带 Origin 请求头。浏览器必须从允许的来源发起连接;任何其他来源或未知的 Host 都会以代码 1008 关闭连接。
登录
服务器接受套接字后,要求在 30 秒内收到 auth 帧:
{
"type": "auth",
"pubkey": "<64 hex>",
"timestamp": 1780000000,
"signature": "<128 hex>"
}
成功:
{ "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。
通用错误帧:
{ "type": "error", "reason": "<reason>", "retry_after": 0 }
速率限制错误还会携带 scope。
关闭代码
| 代码 | 含义 |
|---|---|
1008 | Host 或 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 对象:
{
"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,中继在接受该帧之前会先对其进行验证:
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. 发送
{
"type": "relay_send",
"msg_id": "9e762ae0f5da43e6bec5eb8143f1f834",
"receiver_pubkey": "<64 hex>",
"discriminant": 11,
"payload_b64": "<base64 envelope>",
"timestamp": 1780000000,
"payload_sig": "<128 hex>"
}
| 字段 | 规则 |
|---|---|
msg_id | 32 个小写十六进制字符,即信封中 16 字节的 message_id。接收端 App 以此 ID 存储消息,因此必须与信封保持一致。 |
receiver_pubkey | 接收方的公钥。 |
discriminant | 11。 |
timestamp | 以秒为单位的 Unix 时间戳,与服务器时间的偏差在 ±300 秒以内。 |
suppress_push | 可选。为 true 时不发送推送通知。 |
contact_request | 可选。用于标记发给要求联系人验证的用户的首条消息(参见联系人验证)。 |
4. 确认
{ "type": "relay_delivered", "msg_id": "...", "delivered": true, "queued": true, "durable": true }
- 消息存入接收方的离线队列后,
durable(以及queued)即为true。请将durable: true视为“已发送”。 delivered表示接收方当时有活跃的前台连接。它并不能证明对方已收到消息;如需确认,请使用投递回执。
请最多等待 15 秒以接收确认,超时后再将本次尝试视为失败。
拒绝与错误
{ "type": "send_rejected", "msg_id": "...", "reason": "verification_required" }
| 响应 | 原因 | 处理方式 |
|---|---|---|
send_rejected | verification_required | 接收方只接受联系人发送的消息。请勿重试。 |
send_rejected | contact_request_rate_limited | 针对该接收方的联系请求已达上限。请勿重试。 |
error | missing_fields | 缺少必填字段,或字段为空。 |
error | invalid_timestamp,timestamp_expired | 校正时钟后重新生成时间戳。 |
error | invalid_payload_sig | payload_sig 验证失败。 |
error | rate_limited | 在 retry_after 秒后重试。 |
重试
对于未获确认的消息,请使用相同的 msg_id 和相同的信封字节进行重试。由于中继会拒绝超过 300 秒的帧,每次重试都需要重新计算帧的 timestamp 和 payload_sig;信封本身保留其原始时间戳。中继和接收方均按 msg_id 进行去重。
HTTPS 后备通道
当 WebSocket 不可用时,可以通过 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>"
}
成功时返回 HTTP 200 及 {"success": true}。联系人验证失败时返回 400,并附带 verification_required 或 contact_request_rate_limited;触发速率限制时返回 429,并附带 Retry-After。通过这种方式发送的消息会进入接收方的队列,但不会触发推送通知,因此请在 WebSocket 重新连接后通过 WebSocket 再次发送。
接收消息
传入的消息以 relay_envelope 的形式到达:
{
"type": "relay_envelope",
"sender_pubkey": "<64 hex>",
"discriminant": 11,
"payload_b64": "<base64 envelope>",
"timestamp": 1780000000,
"msg_id": "9e762ae0f5da43e6bec5eb8143f1f834",
"from_offline": false
}
对于 discriminant: 11:
- 按
msg_id去重。同一条消息可能先实时到达,随后又从离线队列再次到达。 - 解析信封,并检查其
receiver_pubkey是否为你的身份。 - 对于实时帧(
from_offline: false),如果信封时间戳与本地时钟相差超过 300 秒,则丢弃该消息。 - 使用信封中的
sender_pubkey验证信封签名。经过认证的发送方是该密钥,而不是帧中的sender_pubkey。 - 使用由该发送方派生的聊天密钥进行解密。非常旧的 App 版本直接使用原始 X25519 输出进行加密;如果使用 HKDF 密钥解密失败,可尝试该方式。
- 将消息持久化存储,然后发送投递回执;对于
from_offline: true的消息,还需发送离线确认。
一对一信封在投递时不携带 payload_sig,信封签名本身即为真实性校验。帧中也可能携带 contact_request: true。
如果消息并非发给你、验证失败或解密失败,请直接丢弃,不显示任何内容。
离线投递
每条消息、编辑、撤回、表情回应、回执和已读回执在实时投递之前,都会先写入接收方的离线队列。每次登录后队列会自动重放,也可以按需请求重放:
{ "type": "relay_pull" }
中继会按时间戳顺序,以各条目原本的帧类型并附带 from_offline: true 重放所有排队条目,随后发送:
{ "type": "relay_pull_done", "count": 12, "has_more": false }
投递语义为至少一次。各条目在得到确认之前会一直保留在队列中:
{ "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 的帧大小限制。 |
中继是投递缓冲区,而不是消息历史记录。请将历史记录保存在设备上。
投递回执与已读回执
投递回执
存储消息后发送:
{ "type": "message_receipt", "receiver_pubkey": "<original sender>", "msg_id": "...", "timestamp": 1780000010 }
中继会回复 message_receipt_ack,并向原发送方投递 {"type":"message_receipt","sender_pubkey":"...","msg_id":"...","timestamp":...}。
已读回执
{ "type": "message_read", "receiver_pubkey": "<original sender>", "msg_id": "<latest read message>", "timestamp": 1780000200, "enabled": true }
已读回执采用水位线机制:App 会将指定消息以及该会话中所有更早的外发消息标记为已读。仅当用户启用了已读回执时才发送。
中继在发送、实时投递和重放三个环节均执行对等规则:只有当双方互为联系人,且都启用了 read_receipts_enabled 时,已读回执才会被投递。否则,发送方会收到:
{ "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 发送:
{
"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。接收方应像处理普通消息一样验证并解密该信封,并且只有当编辑的发送方正是原消息的作者时,才可应用该编辑。
撤回
{
"type": "message_revoke",
"receiver_pubkey": "<64 hex>",
"sender_pubkey": "<64 hex>",
"msg_id": "<original msg_id>",
"timestamp": 1780000500,
"payload_sig": "<128 hex>"
}
撤回签名直接作用于原始字节,不经过哈希:
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"):
{
"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 个表情回应。
群组表情回应在群组一节中说明。
在线状态与正在输入
在线状态与正在输入帧属于明文元数据,对中继可见。
在线状态
订阅联系人:
{ "type": "presence_subscribe", "pubkeys": ["<64 hex>", "<64 hex>"] }
每帧最多处理 200 个公钥;格式错误和重复的公钥会被忽略。订阅会在连接的整个生命周期内持续累积。请只订阅你自己的联系人。
{
"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;否则请显示通用状态,例如“最近在线”。
实时变化以如下形式到达:
{ "type": "presence_update", "pubkey": "<a>", "online": true, "presence_visible": true, "last_seen_visible": true, "last_seen_ts": 1780000100 }
正在输入
{ "type": "typing", "receiver_pubkey": "<64 hex>", "is_typing": true }
{ "type": "group_typing", "group_id": "<uuid>", "is_typing": true }
仅当发送方的在线状态对接收方可见(互为联系人且启用了 presence_enabled),或接收方是发送方所在群组的其他成员时,正在输入提示才会被转发。它永远不会被存储、排队或推送。
个人资料与隐私设置
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"
}
}
尚未设置个人资料的身份会收到空字段,且三个隐私标志均为 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 } }
| 字段 | 规则 |
|---|---|
display_name | 最多 50 个字符。 |
bio | 最多 200 个字符。 |
avatar_url | https:// 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 进行加密:
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。
{
"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))
中继会校验签名并确认发送方为活跃成员,为其他每位活跃成员存储该消息,然后进行实时投递。群组载荷不使用一对一信封;群组信封在投递时会携带 group_id、key_version 和 payload_sig,以便接收方在解密前验证发送方。
{
"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:
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)
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 为密文大小。media_kind 取值为 voice、image、video、file、avatar、other 之一。ttl_days 限制在 1–30 之间(默认为 7)。
{
"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
}
随后:
- 在 15 分钟内将密文通过
PUT上传到upload_url,仅携带Content-Type请求头。不要将Authorization请求头发送给存储服务。 - 携带 RelayAuth 调用
POST /api/relay/blob/{blob_id}/complete/。中继确认对象后返回{blob_id, file_size, expires_at, storage}。
上传:分片上传(最大 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 默认为 8 MiB,也可以请求 5 到 16 MiB 之间的值。分片 URL 的有效期为 60 分钟。将每个分片通过 PUT 上传到对应的 URL,并记录 ETag 响应头,然后:
POST /api/relay/blob/multipart/complete/
{ "blob_id": "<uuid>", "upload_id": "...", "parts": [ { "part_number": 1, "etag": "\"...\"" } ] }
AeroNyx App 在密文不超过 8 MiB 时使用单次请求上传,超过 8 MiB 时使用分片上传。
下载
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 和过期时间在中继重定向环节强制执行,但不要依赖它们来保障机密性。
删除附件
POST /api/relay/blob/{blob_id}/delete/
只有上传者可以删除 blob。响应为 {"blob_id": "...", "deleted": true}。
附件错误
错误采用 {"success": false, "error": "<text>", "error_code": "<code>"} 格式。请根据 error_code 进行分支处理。
| HTTP | error_code | 含义 |
|---|---|---|
| 400 | blob_id_invalid | blob ID 格式错误。 |
| 400 | blob_missing_file_size,blob_missing_total_size,blob_missing_parts,blob_bad_parts,blob_bad_part_count | 上传请求无效。 |
| 400 | blob_not_r2,blob_multipart_complete_failed | 完成上传失败;请重新发起上传。 |
| 401 | auth_required | 该 blob 需要 RelayAuth 认证。 |
| 403 | blob_not_uploader,download_forbidden | 调用方无权执行此操作。 |
| 404 | blob_not_found,blob_not_uploaded | blob 不存在,或在上传尚未完成时调用了完成接口。 |
| 410 | blob_expired | blob 已过期。请让发送方重新发送。 |
| 413 | blob_too_large | 超出大小限制;响应中包含 max_bytes 和 chunked_max_bytes。 |
| 503 | blob_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 会触发推送。编辑、撤回、表情回应和回执不会触发推送。推送载荷不包含任何密文:
{
"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 秒。不要在紧密循环中重复发送:重试期间计数器仍会持续累计。
实施清单
- 生成并存储 Ed25519 身份;所有场景均使用小写十六进制公钥。
- 实现 RelayAuth,以及 WebSocket 登录、心跳和
presence_state。 - 实现密封信封,并使用中心化聊天 HTTPS API v1中的黄金向量进行验证。
- 使用
relay_send发送,将durable: true视为已发送,重试时重新生成timestamp和payload_sig。 - 接收
relay_envelope:去重、验证、解密、存储,然后发送message_receipt和relay_offline_ack。 - 在登录后及被推送唤醒时执行
relay_pull;仅在持久化存储完成后再进行确认。 - 读取个人资料中的隐私标志,并在在线状态和已读回执上遵循这些设置。
- 在设备上加密附件,并通过
presign或分片上传进行上传;将file_key保留在加密消息内部。 - 在应用编辑和撤回之前验证作者身份。
- 在设备上保存消息搜索索引与历史记录。中继不提供内容搜索,也不是归档服务。
常见问题
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 小时窗口内,每个接收方最多允许三次联系人请求。
已验证的两跳投递
对于符合条件且经过认证的 ChatRelay 流量,源端可以选择具备网络多样性的两跳路径,并且只有在验证了预期终端节点的签名回执之后,才将其计为投递成功。中继节点只负责路由密文,不解析端到端加密载荷。完整的证据模型请参阅节点发现与已验证的加密中继投递。
<!-- verified-two-hop-delivery-v1:end -->