AeroNyx Chat Relay 클라이언트 통합
AeroNyx Chat Relay 통합 레퍼런스입니다. WebSocket 인증, 봉인된 1:1 메시지와 그룹 메시지, 오프라인 전달, 수신 확인, 반응, 온라인 상태, 암호화된 첨부 파일, 푸시 알림 및 속도 제한을 다룹니다.
연동 프롬프트 만들기
기술 스택과 작업을 선택한 뒤 코딩 도우미에 복사하세요. 프롬프트는 로컬에서 생성되며 AI 서비스로 전송되지 않습니다.
생성된 프롬프트
이 문서는 AeroNyx Chat Relay의 통합 레퍼런스입니다. AeroNyx Chat Relay는 api.aeronyx.network에서 제공되는 WebSocket 및 HTTPS 서비스로, AeroNyx 신원 간에 종단 간 암호화된 1:1 메시지, 그룹 메시지, 수신 확인, 반응, 온라인 상태, 암호화된 첨부 파일을 전달합니다.
이 문서는 AeroNyx 호환 클라이언트, 봇, 서비스를 개발하는 엔지니어와 이를 구현하는 AI 코딩 에이전트를 대상으로 합니다. 이 문서에 기재된 모든 프레임, 필드, 제한 값은 2026년 10월 기준 프로덕션 릴레이와 AeroNyx App의 동작을 반영합니다.
Chat Relay는 중앙화된 전달 경로입니다. 탈중앙화된 노드 경로(어니언 라우팅 및 익명 메일박스, 검증된 2홉 전달 참조)와 공존하며, 클라이언트는 두 경로를 모두 사용할 수 있습니다. WebSocket 없이 요청/응답 방식의 HTTPS로 통합하려면 Central Chat HTTPS API v1을 참조하십시오.
신뢰 모델
릴레이는 콘텐츠를 볼 수 없습니다. 클라이언트는 릴레이에 도달하기 전에 모든 데이터를 암호화하고 서명하며, 릴레이는 내용을 알 수 없는 암호문을 라우팅하고, 대기열에 넣고, 속도를 제한할 뿐입니다.
릴레이가 절대 수신하지 않는 정보:
- 평문 상태의 메시지 본문, 반응 이모지, 편집 내용 또는 그룹 페이로드
- 채팅 키, 그룹 키, 첨부 파일 키 또는 논스
- 첨부 파일 내용, 파일 이름, 썸네일, 파형 또는 음성 텍스트 변환 결과
- 신원 개인 키
반면 릴레이는 전달 메타데이터를 관찰할 수 있으므로, 통합 개발자는 이를 운영자에게 노출되는 정보로 취급해야 합니다.
- 발신자 및 수신자 공개 키, 그룹 ID, 메시지 ID
- 타임스탬프, 페이로드 크기, 전달·수신 확인·읽음 상태
- 온라인 상태, 포그라운드 상태, 입력 중 표시(평문 프레임으로 전송)
- 통화 시그널링 메타데이터(방 이름, 통화 ID, 영상 플래그)
- 첨부 파일 암호문 크기, 선언된 미디어 유형, 만료 시간
contact_request플래그 및 IP 수준의 연결 메타데이터
콘텐츠의 기밀성은 릴레이의 접근 제어가 아니라 종단 간 암호화를 통해 보장됩니다. 이에 맞게 설계하십시오. 저장된 암호문을 획득한 공격자라도 이를 읽을 수 없어야 합니다.
신원과 키
AeroNyx 채팅 신원은 Ed25519 키 쌍입니다. 32바이트 공개 키를 소문자 16진수 64자로 표기한 것이 주소입니다. 키는 기기에서 생성하고, 개인 키는 절대 어디로도 전송하지 마십시오.
신원 키 쌍에서 다음 두 가지가 파생됩니다.
- 채팅 키(1:1).
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 서명으로, 이 문서에서 정의한 정확한 바이트 문자열에 대해 수행합니다.
프레임에서 공개 키는 항상 소문자 16진수를 사용하십시오. 릴레이는 모든 대기열 키에서 대소문자를 정규화하지는 않습니다.
인증
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초 이내여야 합니다.
이 서명은 신원과 시간만 바인딩합니다. 메서드, 경로, 본문, 연결은 바인딩하지 않으며 논스도 없습니다. 요청마다 새 타임스탬프를 생성하고, 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로 응답하며, 소켓은 열린 상태로 유지됩니다.
하트비트와 포그라운드 상태
| 방향 | 프레임 | 동작 |
|---|---|---|
| 서버 → 클라이언트 | 30초마다 {"type":"ping"} | {"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이 3회를 초과해 누락되면 연결이 끊어진 것으로 처리합니다.
장시간 유지되는 연결은 최소 24시간에 한 번 재연결해야 합니다. 연결이 실시간 프레임 수신을 조용히 멈추더라도 모든 메시지는 대기열에도 저장되어 로그인 시 재전송되므로 메시지가 유실되지는 않습니다. 다만 실시간 전달은 재연결한 후에만 재개됩니다.
프레임 규칙
- 텍스트 프레임만 사용하며, 프레임당 JSON 객체 하나를 담습니다. 바이너리 프레임은 무시됩니다.
- 최대 프레임 크기는 1,048,576자입니다. 이보다 큰 프레임은
{"type":"error","reason":"message_too_large"}로 거부됩니다. 프레임 여유 공간을 남기기 위해payload_b64는 약 800 KiB 미만으로 유지하십시오. - 형식이 잘못된 프레임에는 사유
invalid_json,invalid_json_type또는unknown_type의error가 반환됩니다.
일반 오류 프레임:
{ "type": "error", "reason": "<reason>", "retry_after": 0 }
속도 제한 오류에는 scope도 포함됩니다.
종료 코드
| 코드 | 의미 |
|---|---|
1008 | Host 또는 Origin이 허용되지 않습니다. |
4000 | 서버가 하트비트를 전달하지 못했습니다. |
4001 | 로그인에 실패했습니다. |
4002 | 로그인 시간이 초과되었습니다. |
지수 백오프와 지터를 적용해 재연결하십시오. AeroNyx App은 2^(attempt-1)초(160초로 제한)에 0.81.2 사이의 무작위 계수를 곱한 시간만큼 대기합니다.
1:1 메시지 보내기
1. 봉인 엔벨로프 생성
1:1 메시지의 페이로드는 서명되고 암호화된 ChatEnvelope입니다. 정확한 구성 방법, Python 레퍼런스 구현, 골든 테스트 벡터는 Central Chat 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입니다. 메시지와 편집에는 discriminant 11을, 반응에는 12를 사용합니다. payload_sig는 16진수로 인코딩합니다.
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 | 소문자 16진수 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}가 반환됩니다. 연락처 인증에 실패하면 verification_required 또는 contact_request_rate_limited와 함께 400이 반환되고, 속도 제한에 걸리면 Retry-After와 함께 429가 반환됩니다. 이 방식으로 보낸 메시지는 수신자의 대기열에 저장되지만 푸시 알림을 트리거하지 않으므로, 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인 경우 오프라인 확인 응답도 보냅니다.
1:1 엔벨로프는 payload_sig 없이 전달되며, 엔벨로프 서명으로 진위를 확인합니다. 프레임에는 contact_request: true가 포함될 수도 있습니다.
자신에게 보낸 메시지가 아니거나, 검증 또는 복호화에 실패한 메시지는 아무것도 표시하지 않고 폐기하십시오.
오프라인 전달
모든 메시지, 편집, 회수, 반응, 수신 확인, 읽음 확인은 실시간 전달 전에 수신자의 오프라인 대기열에 기록됩니다. 대기열은 매 로그인 후 자동으로 재전송되며, 요청 시에도 재전송됩니다.
{ "type": "relay_pull" }
릴레이는 대기열의 모든 항목을 원래 프레임 유형 그대로 from_offline: true를 붙여 타임스탬프 순으로 재전송한 다음, 아래 프레임을 보냅니다.
{ "type": "relay_pull_done", "count": 12, "has_more": false }
전달은 최소 1회(at-least-once) 방식입니다. 항목은 확인 응답을 받을 때까지 대기열에 남아 있습니다.
{ "type": "relay_offline_ack", "msg_id": "9e762ae0f5da43e6bec5eb8143f1f834" }
반응에 대한 확인 응답에는 "msg_id" 대신 "reaction_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는 프레임 서명 섹션의 공식을 discriminant 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/를 호출하십시오.
반응
1:1 반응은 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는 discriminant12를 사용합니다.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_ts는 last_seen_visible이 true인 경우에만 포함되며, 그렇지 않으면 "최근 접속" 같은 일반적인 상태를 표시하십시오.
실시간 변경 사항은 다음과 같이 도착합니다.
{ "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))
릴레이는 서명과 발신자가 활성 멤버인지를 확인하고, 다른 모든 활성 멤버를 위해 메시지를 저장한 다음 실시간으로 전달합니다. 그룹 페이로드는 1:1 엔벨로프를 사용하지 않으며, 그룹 엔벨로프는 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바이트 논스를 생성합니다.
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 | 아니요 | 음성 메시지용 [0, 1] 범위의 숫자, 최대 96개. |
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분 이내에
Content-Type헤더만 포함하여 암호문을upload_url에PUT합니다. 스토리지에는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분 동안 유효합니다. 각 파트를 해당 URL에 PUT하고 ETag 응답 헤더를 기록한 다음, 아래 엔드포인트를 호출합니다.
POST /api/relay/blob/multipart/complete/
{ "blob_id": "<uuid>", "upload_id": "...", "parts": [ { "part_number": 1, "etag": "\"...\"" } ] }
AeroNyx App은 암호문이 8 MiB 이하이면 단일 요청 업로드를, 그보다 크면 멀티파트를 사용합니다.
다운로드
GET /api/relay/blob/{blob_id}/
릴레이는 콘텐츠 전송 네트워크상의 Location과 X-AeroNyx-Blob-Size, X-AeroNyx-Blob-Media-Type, X-AeroNyx-Blob-Storage 헤더를 포함한 302로 응답합니다. Authorization 헤더를 전달하지 않고 직접 리디렉션을 따라간 다음, 해당 file_key로 blob을 검증하고 복호화하십시오. 다운로드 크기는 예상 크기로 제한하십시오.
blob_id는 bearer 방식의 접근 권한(capability)입니다. 이를 가진 사람은 누구나 암호문을 가져올 수 있으며, 이것이 키를 암호화된 메시지 안에서만 전달하는 이유입니다. 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/(멀티파트 폼, 최대 10 MiB)와 /api/relay/blob/session/ 아래의 재개 가능한 세션 API(최대 100 MiB, 청크 64 KiB~4 MiB, 세션 유효 기간 24시간)는 폴백으로 계속 사용할 수 있습니다. 새 클라이언트는 위의 엔드포인트를 사용해야 합니다.
푸시 알림
릴레이는 iOS 및 macOS용 Apple Push Notification service(APNs) 알림을 보냅니다. 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(16진수 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을 통해 전달되며, 미디어는 별도로 전송됩니다. 프레임은 1:1 통화의 경우 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을 참가자와 대조해 검증합니다. 1:1 통화는 p2p_ 뒤에 SHA256(lower_key + ":" + higher_key)의 처음 16자리 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: retry_after가 포함된 error rate_limited. HTTPS: Retry-After가 포함된 429. |
발신자 및 그룹별 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 신원을 생성하고 저장하며, 모든 곳에서 소문자 16진수 키를 사용합니다.
- RelayAuth와 WebSocket 로그인, 하트비트,
presence_state를 구현합니다. - 봉인 엔벨로프를 구현하고 Central Chat HTTPS API v1의 골든 벡터로 검증합니다.
relay_send로 전송하고,durable: true를 전송됨으로 처리하며, 재시도 시timestamp와payload_sig를 다시 지정합니다.relay_envelope를 수신하면 중복 제거, 검증, 복호화, 저장을 수행한 다음message_receipt와relay_offline_ack를 보냅니다.- 로그인 후와 푸시로 깨어났을 때
relay_pull을 실행하고, 영구 저장 후에만 확인 응답합니다. - 프로필 개인정보 플래그를 읽고 온라인 상태와 읽음 확인에 이를 적용합니다.
- 첨부 파일은 기기에서 암호화하고
presign또는 멀티파트로 업로드하며,file_key는 암호화된 메시지 안에 보관합니다. - 편집과 회수를 적용하기 전에 작성자를 검증합니다.
- 메시지 검색과 기록은 기기에 보관합니다. 릴레이에는 콘텐츠 검색 기능이 없으며 아카이브도 아닙니다.
자주 묻는 질문
AeroNyx Chat Relay가 제 메시지를 읽을 수 있습니까?
아니요. 메시지, 수정 내용, 반응, 그룹 메시지, 첨부 파일은 릴레이에 도달하기 전에 발신자의 기기에서 암호화되고 서명되며, 키는 대화 참여자의 기기를 벗어나지 않습니다. 릴레이는 암호문만 저장하고 전달합니다.
AeroNyx Chat Relay는 무엇을 볼 수 있습니까?
릴레이는 전송 메타데이터를 볼 수 있습니다. 여기에는 발신자와 수신자의 공개 키, 그룹 ID와 메시지 ID, 타임스탬프, 페이로드 크기, 전송 및 읽음 상태, 접속 상태 및 입력 중 신호, 통화 시그널링 메타데이터, 첨부 파일 크기, 연결 IP 주소가 포함됩니다. 메시지 내용, 첨부 파일 내용, 키는 볼 수 없습니다. 전체 목록은 이 페이지 상단의 신뢰 모델에 나와 있습니다.
AeroNyx 채팅은 어떤 암호화를 사용합니까?
각 신원은 Ed25519 키 쌍입니다. 두 사용자는 X25519와 HKDF-SHA256으로 공유 채팅 키를 도출합니다. 1:1 메시지는 XChaCha20-Poly1305로 암호화되고 Ed25519로 서명됩니다. 그룹 메시지와 첨부 파일은 AES-256-GCM으로 암호화됩니다. 정확한 바이트 형식과 테스트 벡터는 Central Chat HTTPS API v1 문서에 공개되어 있습니다.
사진, 동영상, 파일은 어떻게 보호됩니까?
각 파일은 업로드 전에 기기에서 파일마다 생성되는 무작위 AES-256-GCM 키로 암호화됩니다. 스토리지는 암호문만 받습니다. 파일 키는 종단 간 암호화된 메시지 안에 담겨 전달되므로 수신자만 파일을 복호화할 수 있습니다.
수신자가 오프라인이면 어떻게 됩니까?
릴레이는 암호화된 메시지를 수신자의 오프라인 대기열에 최대 72시간 동안 보관하고, 수신자가 다시 연결되면 전달합니다. iOS와 macOS에서는 메시지 내용이 포함되지 않은 푸시 알림도 수신자에게 전송됩니다.
AeroNyx는 채팅 기록을 보관합니까?
아니요. 릴레이는 전송용 버퍼이며, 수신자의 기기가 항목을 저장했다고 확인하면 해당 항목은 삭제됩니다. 채팅 기록과 검색은 사용자의 기기에 있습니다.
직접 AeroNyx 클라이언트나 봇을 만들 수 있습니까?
예. Ed25519 신원을 보유하고 이 페이지의 형식을 구현한 소프트웨어라면 어떤 것이든 AeroNyx App 사용자와 메시지를 주고받을 수 있습니다. WebSocket 없이 더 간단한 요청/응답 방식으로 연동하려면 Central Chat HTTPS API v1을 사용하십시오.
AeroNyx Chat Relay는 탈중앙화되어 있습니까?
Chat Relay는 AeroNyx의 중앙화된 전송 서비스입니다. AeroNyx는 또한 서로 다른 네트워크를 거치는 2홉 경로로 채팅 암호문을 전달할 수 있는 오픈 소스(AGPL-3.0) 노드 네트워크를 운영합니다. 두 경로는 공존합니다. 릴레이는 빠르고 안정적인 전송과 오프라인 대기열을 제공하며, 노드 경로는 단일 운영자가 관찰할 수 있는 정보를 줄여 주는 선택적 경로입니다.
메시지가 verification_required로 거부되는 이유는 무엇입니까?
수신자는 연락처에서 보낸 메시지만 받기 때문입니다. contact_request: true를 포함한 첫 메시지를 한 번만 보내십시오. 수신자에게는 이 메시지가 연락처 요청으로 표시됩니다. 연락처 요청은 임의의 24시간 동안 수신자당 최대 3건까지 허용됩니다.
검증된 2홉 전달
적격한 인증된 ChatRelay 트래픽의 경우, 발신 측은 네트워크 다양성을 갖춘 2홉 경로를 선택할 수 있으며, 예상된 종단 노드의 서명된 수신 확인을 검증한 후에만 전달 완료로 집계합니다. 릴레이 노드는 암호문을 라우팅할 뿐 E2E 페이로드를 파싱하지 않습니다. 전체 증거 모델은 노드 탐색 및 검증된 암호화 릴레이 전달을 참조하십시오.
<!-- verified-two-hop-delivery-v1:end -->