AeroNyx 채팅 API 연동 가이드

14분 읽기

HTTPS로 종단간 암호화된 AeroNyx 메시지를 주고받는 방법을 다룹니다. 요청 서명, 실행 가능한 빠른 시작, 봉인된 엔벨로프 형식, 오류 코드, 테스트 벡터를 포함합니다.

AeroNyx 채팅 API를 사용하면 서버, 봇 또는 애플리케이션이 일반 HTTPS를 통해 AeroNyx 사용자와 종단간 암호화된 1:1 메시지를 주고받을 수 있습니다. 이 API는 AeroNyx 프라이버시 네트워크, VPN, 어니언 라우팅, 익명 사서함을 사용하지 않습니다.

전송은 실시간입니다. 수신자의 App이 연결되어 있으면 메시지가 즉시 도착합니다. 이 API로 받는 것은 폴링 방식입니다. GET /inbox/는 그 시점에 대기 중인 항목만 반환하며 요청을 열어 두지 않습니다. 실시간으로 받으려면 같은 신원을 Chat Relay WebSocket(wss://api.aeronyx.network/ws/relay/)에 연결하십시오. 아래 '실시간 전달'을 참고하십시오.

기본 URL: https://api.aeronyx.network/api/relay/developer/v1

동작 방식

  1. 신원을 생성합니다. Ed25519 키 쌍이 채팅 신원이 됩니다. 소문자 16진수 64자로 표기한 32바이트 공개 키가 채팅 주소입니다. AeroNyx 계정은 필요하지 않습니다.
  2. 모든 요청에 서명합니다. 각 호출에는 키로 서명한 Authorization 헤더가 포함됩니다(인증).
  3. 각 메시지를 봉인합니다. 메시지를 사용자 측에서 암호화하고 서명한 뒤, 그 결과물인 엔벨로프만 전송합니다(메시지 엔벨로프).
  4. POST /messages/로 전송합니다.
  5. GET /inbox/로 수신하고, 각 메시지를 복호화하여 저장한 다음 POST /acks/로 확인합니다.
엔드포인트인증용도
GET /capabilities/없음서비스 제한 및 지원 기능
POST /messages/필수봉인된 메시지 전송
GET /inbox/필수확인되지 않은 메시지 가져오기
POST /acks/필수저장을 마친 메시지 확인

빠른 시작

이 스크립트는 새 신원 하나에서 다른 신원으로 메시지를 보내고, 다시 읽어 와서 복호화한 뒤 확인(acknowledge)합니다. 아래의 참조 구현(Python)을 aeronyx_chat.py로 저장하고, pip install pynacl requests로 의존성을 설치한 다음 실행하십시오.

python
from __future__ import annotations

import hashlib, json, os, time, uuid

import requests
from nacl.signing import SigningKey

from aeronyx_chat import open_, seal

BASE = "https://api.aeronyx.network"
PREFIX = "/api/relay/developer/v1"


def call(key: SigningKey, method: str, path: str, body: dict | None = None):
    """Signs and sends one request. `path` is the full path after the host."""
    raw = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
    seconds = str(int(time.time()))
    nonce = os.urandom(16)
    digest = hashlib.sha256(
        b"AeroNyx/DeveloperChatRequestV1\0" + method.encode() + b"\0"
        + path.encode() + b"\0" + seconds.encode() + b"\0"
        + nonce + hashlib.sha256(raw).digest()
    ).digest()
    signature = key.sign(digest).signature.hex()
    pub = key.verify_key.encode().hex()
    headers = {
        "Authorization": f"AeroNyx-Chat-V1 {pub}:{seconds}:{nonce.hex()}:{signature}",
        "Content-Type": "application/json",
    }
    # Send exactly the bytes that were signed.
    return requests.request(method, BASE + path, data=raw or None,
                            headers=headers, timeout=20)


alice, bob = SigningKey.generate(), SigningKey.generate()
bob_address = bob.verify_key.encode().hex()

# 1. Alice seals and sends a message to Bob.
message_id = uuid.uuid4()
now = int(time.time())
sent = call(alice, "POST", PREFIX + "/messages/", {
    "receiver_pubkey": bob_address,
    "message_id": str(message_id),
    "discriminant": 11,
    "sent_at": now,
    "payload_b64": seal(alice, bob.verify_key.encode(), "Hello Bob", message_id, now),
})
print("send:", sent.status_code, sent.json())

# 2. Bob fetches his inbox, decrypts, and then acknowledges.
inbox = call(bob, "GET", PREFIX + "/inbox/?limit=50").json()
for item in inbox["messages"]:
    opened = open_(bob, item["payload_b64"])
    print("received:", opened["plaintext"], "from", opened["sender"][:8])
    # Store the message durably here, then acknowledge it.
    ack = call(bob, "POST", PREFIX + "/acks/", {"message_ids": [item["msg_id"]]})
    print("ack:", ack.status_code, ack.json())

인증

GET /capabilities/를 제외한 모든 엔드포인트에는 다음 헤더가 필요합니다.

text
Authorization: AeroNyx-Chat-V1 <public-key-hex>:<unix-seconds>:<nonce-hex>:<signature-hex>
  • public-key-hex는 64자로 된 채팅 주소입니다.
  • unix-seconds는 서버 시계와 60초 이내여야 합니다.
  • nonce-hex는 소문자 16진수 32자로 표기한 무작위 16바이트입니다. 각 nonce는 한 번만 사용할 수 있습니다.
  • signature-hex는 다음 32바이트 다이제스트에 대한 Ed25519 서명이며, 16진수 128자로 표기합니다.
text
SHA256(
  "AeroNyx/DeveloperChatRequestV1\0" ||
  UPPERCASE_HTTP_METHOD || "\0" ||
  PATH_WITH_QUERY || "\0" ||
  ASCII_UNIX_SECONDS || "\0" ||
  NONCE_BYTES ||
  SHA256(EXACT_HTTP_BODY_BYTES)
)

PATH_WITH_QUERY는 /api/relay/developer/v1 접두사와 쿼리 문자열을 포함하여, 전송되는 그대로의 전체 요청 경로입니다. 예를 들면 다음과 같습니다.

요청서명할 PATH_WITH_QUERY
POST https://api.aeronyx.network/api/relay/developer/v1/messages//api/relay/developer/v1/messages/
GET https://api.aeronyx.network/api/relay/developer/v1/inbox/?limit=50/api/relay/developer/v1/inbox/?limit=50

EXACT_HTTP_BODY_BYTES는 전송되는 그대로의 요청 본문 바이트입니다. 따라서 JSON의 공백과 키 순서가 중요합니다. 한 번만 직렬화하고, 그 바이트에 서명한 뒤, 바로 그 바이트를 전송하십시오. 본문이 없는 요청은 빈 문자열의 SHA-256에 서명합니다.

인증 테스트 벡터

API를 호출하기 전에 서명 코드를 확인하는 데 사용하십시오. 이 키는 테스트 전용이며, 실제 메시지에는 절대 사용하지 마십시오.

json
{
  "seed_hex": "0303030303030303030303030303030303030303030303030303030303030303",
  "public_key_hex": "ed4928c628d1c2c6eae90338905995612959273a5c63f93636c14614ac8737d1",
  "method": "POST",
  "path_with_query": "/api/relay/developer/v1/messages/",
  "unix_seconds": "1780000000",
  "nonce_hex": "000102030405060708090a0b0c0d0e0f",
  "body": "{\"receiver_pubkey\":\"8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394\",\"message_id\":\"018f5164-c1b2-7e2b-9d8a-2c60597f4c2f\",\"discriminant\":11,\"sent_at\":1780000000,\"payload_b64\":\"AAAA\"}",
  "body_sha256_hex": "1971a2ef86b2a085e033fd65313981c8668204d49cedbe530875036d1f45c9cc",
  "digest_hex": "6f4d0ca6dcd1221780b2dae45dd94e4bae7e79f266e8bb69f2d50c20de08b276",
  "signature_hex": "14ed2108e1ecf8ce5d04299e59af6d00dec218d62592de127e6a640f235ea4e9703004561fc08e929d0159f92fc51d35a881eec7d0eb16f19d94270c6262cb0c",
  "authorization": "AeroNyx-Chat-V1 ed4928c628d1c2c6eae90338905995612959273a5c63f93636c14614ac8737d1:1780000000:000102030405060708090a0b0c0d0e0f:14ed2108e1ecf8ce5d04299e59af6d00dec218d62592de127e6a640f235ea4e9703004561fc08e929d0159f92fc51d35a881eec7d0eb16f19d94270c6262cb0c"
}

Ed25519 서명은 결정적이므로, 같은 입력이면 정확히 이 signature_hex가 나와야 합니다. body_sha256_hex가 다르다면 본문 바이트가 다른 것입니다.

401 문제 해결

API는 모든 인증 실패에 동일한 401로 응답하므로 다음 항목을 순서대로 확인하십시오.

  1. 시계: unix-seconds가 실제 시각과 60초 이내입니다.
  2. nonce가 요청마다 새로운 값이며, 16바이트이고, 소문자 16진수 32자로 표기되어 있습니다.
  3. 서명한 경로에 /api/relay/developer/v1과 쿼리 문자열이 포함되어 있습니다.
  4. 서명한 메서드가 대문자이며 요청과 일치합니다.
  5. 서명한 본문 바이트가 전송하는 바이트와 같습니다. 서명한 뒤에 JSON을 다시 직렬화하지 마십시오.
  6. 다이제스트와 서명이 위의 테스트 벡터와 일치합니다.

엔드포인트

서비스 정보

GET /capabilities/는 인증이 필요 없으며 현재의 제한과 지원 기능을 반환합니다.

메시지 전송

POST /messages/

json
{
  "receiver_pubkey": "<64 lowercase hex characters>",
  "message_id": "018f5164-c1b2-7e2b-9d8a-2c60597f4c2f",
  "discriminant": 11,
  "sent_at": 1780000000,
  "payload_b64": "<sealed envelope, Base64>"
}

다섯 개 필드는 모두 필수이며 그 외의 필드는 허용되지 않습니다.

  • receiver_pubkey는 수신자의 채팅 주소입니다. 본인의 주소와 달라야 합니다.
  • message_id는 UUID입니다. 메시지마다 새로운 무작위 UUID를 사용하고, 재시도에 대비해 본문을 정확히 그대로 보관하십시오. 같은 message_id를 다른 필드와 함께 보내면 409 idempotency_conflict가 반환됩니다.
  • discriminant는 AeroNyx App 사용자에게 표시되어야 하는 1:1 메시지에서 11입니다.
  • sent_at은 Unix 초 단위이며 서버 시간과 300초 이내여야 합니다.
  • payload_b64는 메시지 엔벨로프에서 설명한 봉인된 엔벨로프입니다.

릴레이는 payload_b64가 1바이트에서 191 KiB 사이의 유효한 Base64인지만 확인합니다. 엔벨로프와 다른 데이터를 구분하지 못하므로, 유효한 엔벨로프가 아닌 페이로드도 여기에서는 수락되며 수신자의 App에서 조용히 폐기됩니다. 엔벨로프를 골든 벡터로 테스트하십시오.

요청이 성공하면 HTTP 202가 반환됩니다.

json
{
  "message_id": "018f5164-c1b2-7e2b-9d8a-2c60597f4c2f",
  "accepted": true,
  "delivered_live": false
}

accepted는 릴레이가 암호문을 영구적으로 저장했다는 의미입니다. delivered_live가 true라는 것은 릴레이가 메시지를 오류 없이 실시간 전달에 넘겼다는 뜻일 뿐입니다. 수신자에게 열린 연결이 하나도 없어도 true이므로, 상대가 온라인인지 판단하는 데 쓸 수 없습니다. 어느 쪽도 수신자가 메시지를 복호화했거나 표시했다는 의미는 아닙니다. 그것은 받은 편지함에 들어오는 전달 확인이 알려 줍니다('받은 편지함의 이벤트' 참고).

받은 편지함 가져오기

GET /inbox/?limit=50

확인되지 않은 이벤트를 최대 100개까지 반환합니다. 같은 이벤트가 타임아웃이나 재시작 이후에 다시 나타날 수 있습니다.

json
{
  "messages": [{
    "sender_pubkey": "<hex>",
    "discriminant": 11,
    "payload_b64": "<ciphertext>",
    "timestamp": 1780000000,
    "msg_id": "018f5164-c1b2-7e2b-9d8a-2c60597f4c2f"
  }],
  "has_more": false
}

has_more가 true이면 저장한 항목을 확인한 뒤 다시 가져오십시오.

받은 편지함의 이벤트

받은 편지함에는 새 메시지뿐 아니라 릴레이가 당신의 신원 앞으로 대기열에 넣은 모든 항목이 들어 있습니다. control_type이 없는 항목은 메시지입니다. '메시지 엔벨로프'에 설명된 대로 payload_b64를 여십시오. control_type이 있는 항목은 어떤 메시지에 관한 이벤트입니다. 무엇이든 복호화하기 전에 control_type을 확인하십시오. 확인 이벤트에는 payload_b64가 없습니다.

control_type의미msg_id
message_receipt수신자의 App이 당신이 보낸 메시지를 복호화하고 저장했습니다.당신 메시지의 message_id
message_read수신자가 당신의 메시지를 읽었습니다. 상대가 읽음 확인을 켠 경우에만 전송됩니다.당신 메시지의 message_id
message_edit발신자가 메시지를 편집했습니다(target_msg_id는 msg_id와 같음).편집된 메시지의 ID
message_revoke발신자가 메시지를 회수했습니다.회수된 메시지의 ID
message_reactiontarget_msg_id에 대한 반응이 추가되거나 제거되었습니다.반응의 ID

편집, 회수, 반응에는 서명(payload_sig)이 있으며 형식은 AeroNyx Chat Relay 클라이언트 통합에 설명되어 있습니다. 어떤 ID를 확인하면 그 ID를 가진 대기열 항목이 모두 제거되므로 한 페이지를 모두 처리한 뒤 확인하십시오. 처리하지 않는 이벤트 유형도 확인하십시오. 그렇지 않으면 다시 반환됩니다.

메시지 확인

POST /acks/

json
{
  "message_ids": ["018f5164-c1b2-7e2b-9d8a-2c60597f4c2f"]
}

메시지는 검증하고, 복호화하고, 영구 저장소에 기록한 뒤에만 확인하십시오. 알 수 없는 ID는 응답의 acknowledged 목록에서 제외됩니다.

전달 모델

  • 전달은 최소 한 번(at least once)입니다. control_type과 msg_id를 함께 사용해 중복을 제거하십시오. 확인 이벤트는 자신이 확인하는 메시지의 ID를 담고 있습니다.
  • 전달되지 않은 암호문은 72시간 동안 보관됩니다. 이 서비스를 영구적인 기록 저장소로 취급하지 마십시오.
  • 실패한 전송은 정확히 같은 본문과 새로운 인증 nonce로 재시도하십시오.

실시간 전달

보내기

POST /messages/는 수신자가 열어 둔 모든 연결에 메시지를 넘기는 동시에, 수신자의 App이 확인할 때까지 대기열에 보관합니다. 수신자의 App이 연결되어 있으면 즉시 받고, 오프라인이면 App이 다음에 연결될 때 받습니다(72시간 이내).

이 API로 보낸 메시지는 푸시 알림을 발생시키지 않습니다. App을 닫아 둔 수신자는 알림을 받지 못하며, 다음에 App을 열 때 메시지를 보게 됩니다.

폴링으로 받기

GET /inbox/는 대기 중인 항목이 있든 없든 즉시 반환됩니다. 메시지는 다음 호출 때에야 받을 수 있으므로, 체감 지연은 곧 폴링 간격입니다. 대화형 용도라면 WebSocket으로 받으십시오.

실시간으로 받기

같은 신원을 Chat Relay WebSocket에 연결하십시오. 계정이나 등록은 필요 없습니다.

  1. wss://api.aeronyx.network/ws/relay/를 열고 같은 Ed25519 키로 로그인합니다. 방법은 AeroNyx Chat Relay 클라이언트 통합의 로그인 절을 참고하십시오.
  2. 로그인하면 릴레이가 먼저 대기열에 남아 있는 모든 항목을 다시 보내고(from_offline이 true), 그 뒤로는 새 항목이 도착하는 즉시 보냅니다. 메시지는 받은 편지함의 메시지와 같은 필드를 가진 relay_envelope 프레임으로 도착하고, 확인·편집·회수·반응은 type이 위 표의 control_type인 프레임으로 도착합니다.
  3. 항목을 저장한 뒤 확인합니다.
json
{"type": "relay_offline_ack", "msg_id": "018f5164-c1b2-7e2b-9d8a-2c60597f4c2f"}

WebSocket과 GET /inbox/는 같은 대기열을 읽습니다. 한쪽에서 확인한 항목은 다른 쪽에서도 사라지며, POST /acks/는 양쪽 모두에 쓸 수 있습니다. WebSocket으로 받으면서 보내기는 계속 HTTPS로 할 수 있습니다.

제한 및 오류

제한값
봉인된 페이로드191 KiB (Base64 JSON 요청이 요청 제한 미만으로 유지됩니다)
요청 본문256 KiB
받은 편지함 페이지 및 확인 일괄 처리100개 항목
오프라인 보관72시간
상태의미
400 invalid_json본문이 JSON 객체가 아닙니다.
400 invalid_request본문에 정확히 다섯 개의 필드가 들어 있지 않거나, receiver_pubkey가 소문자 16진수 64자가 아니거나 본인의 주소와 같거나, discriminant가 1-255 범위를 벗어나거나, message_id가 UUID가 아니거나, payload_b64가 유효한 Base64가 아닙니다.
400 invalid_payload디코딩된 페이로드가 비어 있거나 191 KiB보다 큽니다.
400 message_expiredsent_at이 서버 시간과 300초를 초과하여 차이가 납니다.
401인증이 없거나, 만료되었거나, 유효하지 않거나, 재전송되었습니다. 401 문제 해결을 참조하십시오.
403수신자의 정책이 이 발신자를 거부했습니다.
409멱등성 충돌이거나, 동일한 요청이 아직 처리 중입니다.
429발신자 속도 제한에 도달했습니다. 신원당 초당 50건, UTC 하루 100,000건입니다. Retry-After 헤더에 표시된 시간만큼 기다리세요.
503영구 전달 또는 인증을 일시적으로 사용할 수 없습니다. 새로운 nonce로 정확히 같은 본문을 재시도하십시오.

이 가이드에서 다루지 않는 내용

이 가이드는 HTTPS를 통한 일대일 메시지만 다룹니다. 그 밖의 기능은 같은 신원으로 채팅 릴레이 WebSocket에 연결하고 AeroNyx Chat Relay 클라이언트 통합를 참고하세요.

  • 그룹 메시지, 접속 상태, 입력 중 표시.
  • 음성 메시지와 동영상 파일을 포함한 암호화된 첨부 파일.
  • 음성·영상 통화. 시그널링 프레임은 해당 문서에 나열되어 있지만, 전체 통화 연동 가이드(미디어 토큰, 초대 서명, 통화 암호화)는 아직 공개되지 않았습니다.

개인정보 보호 및 가시성

메시지 내용은 종단간 암호화됩니다. 내용은 발신자와 수신자의 신원에서 파생된 키로 사용자 측에서 봉인되며, AeroNyx는 그 키를 보유하지 않습니다. API는 암호문만 저장하고 전달합니다. 메시지를 전달하기 위해 서비스는 발신자와 수신자의 공개 키, 메시지 시각, 크기, 전달 상태, 그리고 각 요청의 IP 주소를 볼 수 있습니다. HTTPS는 이 메타데이터를 서비스로부터 숨기지 않습니다.

메시지 엔벨로프

AeroNyx App 사용자에게 표시되어야 하는 메시지는 discriminant: 11과 payload_b64 안의 종단간 봉인된 엔벨로프를 사용합니다. 릴레이는 키나 평문을 절대 볼 수 없습니다. 모든 정수는 리틀 엔디언입니다.

1. 공유 키

양측은 각자의 Ed25519 채팅 신원에서 동일한 32바이트 키를 파생합니다.

text
my_x25519_secret = clamp(SHA-512(my_ed25519_seed)[0..32])
peer_x25519      = Edwards-to-Montgomery(peer_ed25519_public_key)
raw              = X25519(my_x25519_secret, peer_x25519)
key              = HKDF-SHA256(salt = empty, ikm = raw, info = "AERONYX-P2P-KEY", length = 32)

libsodium에서는 crypto_sign_ed25519_sk_to_curve25519, crypto_sign_ed25519_pk_to_curve25519, crypto_scalarmult를 차례로 사용한 뒤 HKDF를 적용합니다. 발신자는 항상 HKDF 키를 사용합니다. 매우 오래된 App 버전은 raw를 직접 사용하여 전송했으므로, 수신자는 HKDF 키로 복호화에 실패하면 raw를 시도할 수 있습니다.

2. 평문

텍스트 메시지는 UTF-8 텍스트 그 자체입니다. 첨부 파일, 답장 또는 전달을 포함하는 App의 메시지는 "type": "aeronyx_message"와 text 필드를 가진 JSON 객체입니다. 나머지를 지원하지 않는 경우 text를 표시하십시오.

3. 암호화

무작위로 새로 생성한 24바이트 nonce를 사용하는 XChaCha20-Poly1305이며, 연관 데이터는 없습니다. 아래의 ciphertext는 16바이트 태그를 포함한 암호 출력입니다.

4. 서명

발신자는 다음 121바이트에 Ed25519로 서명합니다.

text
sender_pubkey   32 bytes
message_id      16 bytes   (the raw bytes of the UUID sent as message_id)
receiver_pubkey 32 bytes
timestamp        8 bytes   (u64, Unix seconds; use the same value as sent_at)
content_type     1 byte    (0 = text)
SHA-256(ciphertext) 32 bytes

5. 엔벨로프

text
message_id      16 bytes
sender_pubkey   32 bytes
receiver_pubkey 32 bytes
timestamp        8 bytes   (u64)
ciphertext_len   8 bytes   (u64)
ciphertext       ciphertext_len bytes
nonce           24 bytes
content_type     4 bytes   (u32, 0 = text)
signature       64 bytes

payload_b64는 이 바이트들의 표준 Base64입니다. content_type은 서명되는 데이터에서는 1바이트이지만 엔벨로프에서는 4바이트라는 점에 유의하십시오.

수신

엔벨로프를 파싱하고, receiver_pubkey가 본인의 신원인지 확인하고, 엔벨로프의 sender_pubkey에 대해 서명을 검증한 다음, 해당 발신자로부터 파생한 키로 복호화합니다. 어느 단계든 실패하면 메시지를 폐기하십시오. App은 timestamp가 자신의 시계와 300초를 초과하여 차이가 나는 실시간 메시지를 폐기합니다.

참조 구현(Python)

python
"""AeroNyx 1:1 chat envelope (discriminant 11). Requires PyNaCl."""

from __future__ import annotations

import base64
import hashlib
import hmac
import os
import struct
import uuid

from nacl.bindings import (
    crypto_aead_xchacha20poly1305_ietf_decrypt,
    crypto_aead_xchacha20poly1305_ietf_encrypt,
    crypto_scalarmult,
    crypto_sign_ed25519_pk_to_curve25519,
    crypto_sign_ed25519_sk_to_curve25519,
)
from nacl.signing import SigningKey, VerifyKey

CONTENT_TEXT = 0
_HKDF_INFO = b"AERONYX-P2P-KEY"


def _hkdf_sha256(ikm: bytes, info: bytes, length: int = 32) -> bytes:
    # RFC 5869 with an empty salt (= 32 zero bytes).
    prk = hmac.new(b"\x00" * 32, ikm, hashlib.sha256).digest()
    okm, block, counter = b"", b"", 1
    while len(okm) < length:
        block = hmac.new(prk, block + info + bytes([counter]), hashlib.sha256).digest()
        okm += block
        counter += 1
    return okm[:length]


def chat_key(my_key: SigningKey, peer_public: bytes, hkdf: bool = True) -> bytes:
    """X25519 over the Ed25519 identities, then HKDF-SHA256."""
    my_secret = bytes(my_key) + my_key.verify_key.encode()  # libsodium 64-byte sk
    raw = crypto_scalarmult(
        crypto_sign_ed25519_sk_to_curve25519(my_secret),
        crypto_sign_ed25519_pk_to_curve25519(peer_public),
    )
    return _hkdf_sha256(raw, _HKDF_INFO) if hkdf else raw


def _sign_data(sender, message_id, receiver, timestamp, content_type, ciphertext):
    return (
        sender
        + message_id
        + receiver
        + struct.pack("<Q", timestamp)
        + bytes([content_type])  # one byte here ...
        + hashlib.sha256(ciphertext).digest()
    )


def seal(my_key: SigningKey, receiver: bytes, text: str, message_id: uuid.UUID,
         timestamp: int, nonce: bytes | None = None) -> str:
    """Returns payload_b64 for POST /messages/ (discriminant 11)."""
    nonce = nonce if nonce is not None else os.urandom(24)
    key = chat_key(my_key, receiver)
    ciphertext = crypto_aead_xchacha20poly1305_ietf_encrypt(
        text.encode("utf-8"), None, nonce, key)  # ciphertext || 16-byte tag, no AAD
    sender = my_key.verify_key.encode()
    mid = message_id.bytes
    signature = my_key.sign(
        _sign_data(sender, mid, receiver, timestamp, CONTENT_TEXT, ciphertext)
    ).signature
    envelope = (
        mid
        + sender
        + receiver
        + struct.pack("<Q", timestamp)
        + struct.pack("<Q", len(ciphertext))
        + ciphertext
        + nonce
        + struct.pack("<I", CONTENT_TEXT)  # ... four bytes here
        + signature
    )
    return base64.b64encode(envelope).decode("ascii")


def open_(my_key: SigningKey, payload_b64: str) -> dict:
    """Verifies and decrypts an inbox payload addressed to my_key."""
    env = base64.b64decode(payload_b64, validate=True)
    if len(env) < 16 + 32 + 32 + 8 + 8 + 16 + 24 + 4 + 64:
        raise ValueError("envelope too short")
    mid, sender, receiver = env[0:16], env[16:48], env[48:80]
    timestamp, ct_len = struct.unpack("<QQ", env[80:96])
    if len(env) != 96 + ct_len + 24 + 4 + 64:
        raise ValueError("envelope length mismatch")
    ciphertext = env[96:96 + ct_len]
    nonce = env[96 + ct_len:120 + ct_len]
    (content_type,) = struct.unpack("<I", env[120 + ct_len:124 + ct_len])
    signature = env[124 + ct_len:]
    if receiver != my_key.verify_key.encode():
        raise ValueError("envelope is addressed to another identity")
    VerifyKey(sender).verify(
        _sign_data(sender, mid, receiver, timestamp, content_type, ciphertext), signature)
    # Current App versions use the HKDF key; very old ones sent with the raw key.
    for hkdf in (True, False):
        try:
            plaintext = crypto_aead_xchacha20poly1305_ietf_decrypt(
                ciphertext, None, nonce, chat_key(my_key, sender, hkdf))
            break
        except Exception:  # noqa: BLE001 - try the legacy key next
            plaintext = None
    if plaintext is None:
        raise ValueError("decryption failed")
    return {
        "message_id": str(uuid.UUID(bytes=mid)),
        "sender": sender.hex(),
        "timestamp": timestamp,
        "content_type": content_type,
        "plaintext": plaintext.decode("utf-8"),
    }

골든 벡터

고정된 키, nonce, ID, 시각이며 테스트 전용입니다. 이 키는 절대 사용하지 마십시오.

json
{
  "sender_seed_hex": "0101010101010101010101010101010101010101010101010101010101010101",
  "sender_pubkey": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c",
  "receiver_seed_hex": "0202020202020202020202020202020202020202020202020202020202020202",
  "receiver_pubkey": "8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394",
  "message_id": "018f5164-c1b2-7e2b-9d8a-2c60597f4c2f",
  "timestamp": 1780000000,
  "nonce_hex": "000102030405060708090a0b0c0d0e0f1011121314151617",
  "plaintext": "Hello from AeroNyx",
  "shared_key_hex": "dd08f8bb45a43c9bc186fdaf436d47e8e05b0aca44fa2bb9473769da0407652f",
  "payload_b64": "AY9RZMGyfiudiixgWX9ML4qI4910CfGV/VLbLTy6XXLKZwm/HZQSG/N0iAG0D29cgTl3Dqh9F19Wo1Rmw0x+zMuNipG07jeiXfYPW4/Js5QApRhqAAAAACIAAAAAAAAA1vVyzdTx1kAdPEI7AGO12ppwLwaME0K9PJKd+u7OgWq0rwABAgMEBQYHCAkKCwwNDg8QERITFBUWFwAAAACAlpInhB5A9cD4LqKAT0OE0OpbMqT5w/xB8IiHrzuyg9LPjF9gDHdOekPNoHNxDMeu3Ift0UB69OGSofdfwKIN"
}

seal(alice, bob_public, "Hello from AeroNyx", message_id, 1780000000, nonce=bytes(range(24)))는 정확히 이 payload_b64를 생성해야 하며, open_(bob, payload_b64)는 평문을 반환해야 합니다. 이 벡터는 AeroNyx App 자체의 키 합의, 서명기, 암호와 대조하여, 그리고 노드의 엄격한 엔벨로프 디코더와 대조하여 검증되었습니다.

<!-- faq:start -->

자주 묻는 질문

AeroNyx 채팅 API란 무엇입니까?

서버, 봇 또는 애플리케이션에서 AeroNyx 사용자와 종단간 암호화된 1:1 메시지를 주고받기 위한 버전 관리되는 HTTPS API입니다. 기능 조회(capabilities), 전송, 받은 편지함, 확인(acknowledge)의 네 가지 엔드포인트가 있습니다. 봉인된 엔벨로프만 전달하며, 내용은 사용자 측에서 암호화됩니다.

API를 사용하려면 AeroNyx 계정이 필요합니까?

아니요. Ed25519 키 쌍이 곧 신원입니다. 로컬에서 생성하여 각 요청에 서명하는 데 사용하십시오. 공개 키가 채팅 주소입니다.

/messages/에 서명합니까, 전체 경로에 서명합니까?

전송되는 그대로의 전체 경로이며, /api/relay/developer/v1로 시작하고 쿼리 문자열이 있으면 그것도 포함합니다. /messages/만 서명하면 401이 발생합니다. 이 페이지의 인증 테스트 벡터에 정확한 입력이 나와 있습니다.

왜 401이 발생합니까?

모든 인증 실패는 동일한 401을 반환합니다. 일반적인 원인은 시계가 60초를 초과하여 어긋나 있거나, nonce를 재사용했거나, 서명한 경로 또는 본문이 전송한 내용과 다르거나, 서명 데이터의 HTTP 메서드가 소문자인 경우입니다. 401 문제 해결의 점검 목록을 차례로 확인하고 다이제스트를 테스트 벡터와 비교하십시오.

왜 API는 202를 반환하는데 메시지가 AeroNyx App에 나타나지 않습니까?

릴레이는 유효한 Base64 페이로드라면 무엇이든 수락하지만, App은 검증하고 복호화할 수 있는 봉인된 엔벨로프만 표시합니다. payload_b64를 이 페이지의 골든 벡터와 대조하고, 엔벨로프의 receiver_pubkey가 수신자인지 확인하며, 엔벨로프의 타임스탬프를 현재 시각과 300초 이내로 유지하십시오. Base64로 인코딩한 일반 텍스트를 보내면 수락은 되지만 표시되지는 않습니다.

왜 API가 invalid_request를 반환합니까?

가장 흔한 원인은 본인의 공개 키로 전송한 경우, 본문에 문서화된 다섯 개 필드가 정확히 들어 있지 않은 경우, UUID가 아닌 message_id, 또는 1-255 범위를 벗어난 discriminant입니다. 제한 및 오류의 오류 표를 참조하십시오.

AeroNyx는 이 API로 보낸 메시지를 읽을 수 있습니까?

아니요. 엔벨로프는 발신자와 수신자의 신원에서 파생된 키로 암호화되며, AeroNyx는 그 키를 보유하지 않습니다. 서비스는 공개 키, 시각, 크기, IP 주소 같은 전달 메타데이터를 볼 수 있습니다.

전달되지 않은 메시지는 얼마나 오래 보관됩니까?

최대 72시간입니다. 각 메시지는 복호화하여 저장한 뒤에만 확인하십시오. 확인하지 않은 메시지는 다시 전달될 수 있습니다.

어떤 프로그래밍 언어를 사용할 수 있습니까?

Ed25519, X25519, HKDF-SHA256, XChaCha20-Poly1305를 지원하는 모든 언어를 사용할 수 있으며, 대부분의 언어에 libsodium 바인딩이 있습니다. 이 페이지에는 완전한 Python 참조 구현과 실행 가능한 빠른 시작이 포함되어 있습니다.

AeroNyx 채팅 API는 실시간인가요?

보내기는 실시간입니다. 수신자의 App이 연결되어 있으면 메시지를 즉시 받습니다. HTTPS로 받는 것은 폴링이므로, 새 메시지는 다음에 GET /inbox/를 호출할 때 보입니다. 실시간으로 받으려면 같은 신원을 Chat Relay WebSocket(wss://api.aeronyx.network/ws/relay/)에 연결하십시오. 같은 대기열을 읽습니다.

제가 보낸 메시지로 AeroNyx 사용자가 알림을 받나요?

현재는 받지 않습니다. 이 API로 보낸 메시지는 푸시 알림을 발생시키지 않으므로, App을 닫아 둔 사용자는 다음에 App을 열 때 메시지를 봅니다. App이 연결된 사용자는 즉시 봅니다.

<!-- faq:end -->