دليل تكامل واجهة AeroNyx Chat API

13 دقيقة قراءة

أرسل رسائل AeroNyx المشفّرة من طرف إلى طرف واستقبلها عبر HTTPS: توقيع الطلبات، وبداية سريعة قابلة للتشغيل، وصيغة المغلف المختوم، ورموز الأخطاء، ومتجهات الاختبار.

تتيح AeroNyx Chat API لخادم أو روبوت أو تطبيق إرسال رسائل فردية مشفّرة من طرف إلى طرف إلى مستخدمي AeroNyx واستقبالها منهم عبر HTTPS العادي. وهي لا تستخدم شبكة الخصوصية AeroNyx ولا VPN ولا التوجيه البصلي ولا صندوق البريد المجهول.

الإرسال فوري: إذا كان App المستلم متصلًا، تصل رسالتك إليه على الفور. أما الاستلام عبر هذه الواجهة فيعمل بالاستطلاع: يعيد GET /inbox/ ما ينتظر الآن فقط ولا يُبقي الطلب مفتوحًا أبدًا. للاستلام في الوقت الفعلي، صِل الهوية نفسها بـ WebSocket الخاص بـ Chat Relay (wss://api.aeronyx.network/ws/relay/). راجع قسم «التسليم في الوقت الفعلي» أدناه.

عنوان URL الأساسي: https://api.aeronyx.network/api/relay/developer/v1

آلية العمل

  1. أنشئ هوية. زوج مفاتيح Ed25519 هو هوية الدردشة الخاصة بك. المفتاح العام ذو الـ 32 بايتًا، المكتوب في صورة 64 حرفًا سداسيًا عشريًا بأحرف صغيرة، هو عنوان الدردشة الخاص بك. لا حاجة إلى حساب AeroNyx.
  2. وقّع كل طلب. يحمل كل استدعاء ترويسة Authorization موقَّعة بمفتاحك (المصادقة).
  3. اختم كل رسالة. شفّر الرسالة ووقّعها من جانبك، وأرسل المغلف الناتج فقط (مغلف الرسالة).
  4. أرسلها باستخدام POST /messages/.
  5. استقبل باستخدام GET /inbox/، ثم فُكّ تشفير كل رسالة واحفظها، ثم أكّد استلامها باستخدام POST /acks/.
نقطة النهايةالمصادقةالغرض
GET /capabilities/لا توجدحدود الخدمة والميزات المدعومة
POST /messages/مطلوبةإرسال رسالة مختومة
GET /inbox/مطلوبةجلب الرسائل غير المؤكَّد استلامها
POST /acks/مطلوبةتأكيد استلام الرسائل التي حفظتها

البدء السريع

يرسل هذا السكربت رسالة من هوية جديدة إلى هوية أخرى، ثم يقرؤها ويفك تشفيرها ويؤكد استلامها. احفظ التنفيذ المرجعي الوارد أدناه باسم 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 حرفًا سداسيًا عشريًا بأحرف صغيرة. لا يمكن استخدام كل nonce إلا مرة واحدة.
  • signature-hex هو توقيع Ed25519، مكتوب في صورة 128 حرفًا سداسيًا عشريًا، على هذا الملخص ذي الـ 32 بايتًا:
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 للسلسلة الفارغة.

متجه اختبار المصادقة

استخدمه للتحقق من شيفرة التوقيع لديك قبل استدعاء الواجهة. هذا المفتاح مخصص للاختبار فقط؛ لا تستخدمه أبدًا مع رسائل حقيقية.

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 وإصلاحها

ترد الواجهة على كل إخفاق في المصادقة بالرمز 401 نفسه، لذا تحقق مما يلي بالترتيب:

  1. الساعة: أن يكون unix-seconds ضمن 60 ثانية من الوقت الفعلي.
  2. أن تكون قيمة nonce جديدة في كل طلب، وبطول 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 هو 11 للرسالة الفردية التي ينبغي أن يراها مستخدم AeroNyx App.
  • sent_at هو وقت Unix بالثواني، ويجب أن يكون ضمن 300 ثانية من وقت الخادم.
  • payload_b64 هو المغلف المختوم الموصوف في مغلف الرسالة.

لا يتحقق المرحِّل إلا من أن payload_b64 نص Base64 صالح حجمه من 1 بايت إلى 191 KiB. ولا يستطيع التمييز بين المغلف وبيانات أخرى، لذا فإن الحمولة التي ليست مغلفًا صالحًا تُقبل هنا ثم يُسقطها تطبيق المستلم بصمت. اختبر مغلفك بالمقارنة مع المتجه الذهبي.

عند نجاح الطلب يعيد 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 هو المعرّف نفسه).معرّف الرسالة المعدّلة
message_revokeسحب المرسل رسالة.معرّف الرسالة المسحوبة
message_reactionأُضيف تفاعل على target_msg_id أو أُزيل.معرّف التفاعل

التعديلات وعمليات السحب والتفاعلات موقّعة (payload_sig)، وصيغها موضحة في تكامل العملاء مع مُرحِّل الدردشة في AeroNyx. تأكيد معرّف ما يزيل كل عناصر قائمة الانتظار التي تحمله، لذلك عالج صفحة كاملة قبل التأكيد. أكّد أيضًا أنواع الأحداث التي لا تعالجها، وإلا فستُعاد مرة أخرى.

تأكيد استلام الرسائل

POST /acks/

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

لا تؤكد استلام رسالة إلا بعد أن تتحقق منها وتفك تشفيرها وتكتبها في تخزين دائم. وتُستبعد المعرّفات غير المعروفة من قائمة acknowledged في الاستجابة.

نموذج التسليم

  • التسليم مرة واحدة على الأقل. أزل التكرار باستخدام control_type مع msg_id معًا: يحمل الإيصال معرّف الرسالة التي يؤكدها.
  • يُحتفظ بالنص المشفّر غير المسلَّم لمدة 72 ساعة. لا تتعامل مع الخدمة على أنها سجل دائم.
  • أعد محاولة الإرسال الفاشل بالجسم نفسه تمامًا ومع nonce مصادقة جديد.

التسليم في الوقت الفعلي

الإرسال

يسلّم POST /messages/ رسالتك إلى كل اتصال يفتحه المستلم، ويحتفظ بها في الوقت نفسه في قائمة الانتظار حتى يؤكدها App المستلم. المستلم الذي يكون App لديه متصلًا يتلقاها فورًا؛ أما غير المتصل فيتلقاها عند اتصال App في المرة التالية، خلال 72 ساعة.

الرسائل المرسلة عبر هذه الواجهة لا تُطلق إشعارًا فوريًا (push). المستلم الذي يكون App لديه مغلقًا لا يُنبَّه، وسيرى الرسالة عندما يفتح App في المرة التالية.

الاستلام بالاستطلاع

يعود GET /inbox/ فورًا، سواء كان هناك ما ينتظر أم لا. لا تصل إليك الرسالة إلا في استدعائك التالي، لذا فالتأخير الذي تراه هو فاصل الاستطلاع لديك. للمحادثات، استلم عبر WebSocket بدلًا من ذلك.

الاستلام في الوقت الفعلي

صِل الهوية نفسها بـ WebSocket الخاص بـ Chat Relay. لا حاجة إلى حساب أو تسجيل.

  1. افتح wss://api.aeronyx.network/ws/relay/ وسجّل الدخول بمفتاح Ed25519 نفسه، كما هو موضح في جزء تسجيل الدخول من تكامل العملاء مع مُرحِّل الدردشة في AeroNyx.
  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/ يعمل مع كليهما. يمكنك مواصلة الإرسال عبر HTTPS بينما تستلم عبر WebSocket.

الحدود والأخطاء

الحدالقيمة
الحمولة المختومة191 KiB (يظل طلب JSON بترميز Base64 أقل من حد الطلب)
جسم الطلب256 KiB
صفحة صندوق الوارد ودفعة التأكيد100 عنصر
مدة الاحتفاظ دون اتصال72 ساعة
الحالةالمعنى
400 invalid_jsonالجسم ليس كائن JSON.
400 invalid_requestلا يحتوي الجسم على الحقول الخمسة بالضبط، أو أن receiver_pubkey ليس 64 حرفًا سداسيًا عشريًا بأحرف صغيرة أو يساوي عنوانك، أو أن discriminant خارج النطاق 1-255، أو أن message_id ليس UUID، أو أن payload_b64 ليس Base64 صالحًا.
400 invalid_payloadالحمولة بعد فك ترميزها فارغة أو أكبر من 191 KiB.
400 message_expiredيختلف sent_at عن وقت الخادم بأكثر من 300 ثانية.
401المصادقة مفقودة أو منتهية الصلاحية أو غير صالحة أو معاد استخدامها. راجع استكشاف أخطاء 401 وإصلاحها.
403رفضت سياسة المستلم هذا المرسِل.
409تعارض في idempotency، أو أن طلبًا مطابقًا لا يزال قيد المعالجة.
429تم بلوغ حد معدل المرسِل: 50 طلبًا في الثانية و100,000 طلب في اليوم بالتوقيت العالمي المنسق لكل هوية. انتظر المدة المذكورة في ترويسة Retry-After.
503التسليم الدائم أو المصادقة غير متاح مؤقتًا. أعد المحاولة بالجسم نفسه تمامًا مع nonce جديد.

ما لا يغطيه هذا الدليل

يغطي هذا الدليل الرسائل الفردية عبر HTTPS. لأي شيء آخر، اربط الهوية نفسها بـ WebSocket لمُرحِّل الدردشة وراجع تكامل العملاء مع مُرحِّل الدردشة في AeroNyx:

  • رسائل المجموعات وحالة الحضور ومؤشرات الكتابة.
  • المرفقات المشفّرة، بما فيها الرسائل الصوتية وملفات الفيديو.
  • المكالمات الصوتية والمرئية. إطارات التشوير مدرجة في تلك المقالة، لكن دليل تكامل المكالمات الكامل (رمز الوسائط وتوقيع الدعوة وتشفير المكالمة) لم يُنشر بعد.

الخصوصية والرؤية

محتوى الرسالة مشفّر من طرف إلى طرف: فهو يُختم من جانبك بمفتاح مشتق من هويتَي المرسِل والمستلم، لا تحتفظ AeroNyx به أبدًا، وتخزّن الواجهة النص المشفّر فقط وتمرّره. ولتسليم الرسائل تستطيع الخدمة رؤية المفتاحين العامين للمرسِل والمستلم، ووقت الرسالة وحجمها، وحالة التسليم، وعنوان IP لكل طلب. ولا يخفي HTTPS هذه البيانات الوصفية عن الخدمة.

مغلف الرسالة

تستخدم الرسائل التي ينبغي أن يراها مستخدم AeroNyx App القيمة discriminant: 11 ومغلفًا مختومًا من طرف إلى طرف في payload_b64. ولا يرى المرحِّل المفتاح ولا النص الأصلي أبدًا. جميع الأعداد الصحيحة بترتيب البايتات الأصغر أولًا (little-endian).

1. المفتاح المشترك

يشتق الطرفان المفتاح نفسه ذا الـ 32 بايتًا من هويتَي الدردشة Ed25519 الخاصتين بهما:

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. أما إصدارات التطبيق القديمة جدًا فكانت ترسل باستخدام raw مباشرةً، لذا يجوز للمستلم أن يجرّب raw إذا فشل فك التشفير بمفتاح HKDF.

2. النص الأصلي

الرسالة النصية هي نصها بترميز UTF-8. أما الرسائل الواردة من التطبيق التي تحمل مرفقات أو ردودًا أو إعادة توجيه فهي كائن JSON يحتوي على "type": "aeronyx_message" وحقل text؛ اعرض text إذا كنت لا تدعم بقية المحتوى.

3. التشفير

XChaCha20-Poly1305 مع nonce عشوائي جديد بطول 24 بايتًا ودون بيانات مصاحبة (associated data). وciphertext أدناه هو ناتج التشفير شاملًا وسمه (tag) البالغ 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 بايت واحد داخل البيانات الموقَّعة لكنه 4 بايتات في المغلف.

الاستقبال

حلّل المغلف، وتحقق من أن receiver_pubkey هو هويتك، وتحقق من التوقيع مقابل sender_pubkey الوارد في المغلف، ثم فُكّ التشفير بالمفتاح المشتق من ذلك المرسِل. أسقط الرسالة إذا فشلت أي خطوة. ويُسقط التطبيق الرسائل الحية التي يبعد 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 ومعرّف ووقت ثابتة، للاختبار فقط. لا تستخدم هذه المفاتيح أبدًا.

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 نفسه، ومقابل مفكّك المغلفات الصارم في العقدة.

<!-- faq:start -->

الأسئلة الشائعة

ما هي AeroNyx Chat API؟

هي واجهة HTTPS ذات إصدارات لتبادل رسائل فردية مشفّرة من طرف إلى طرف مع مستخدمي AeroNyx من خادم أو روبوت أو تطبيق. وتضم أربع نقاط نهاية: القدرات والإرسال وصندوق الوارد وتأكيد الاستلام. وهي تنقل المغلفات المختومة فقط؛ إذ يُشفَّر المحتوى من جانبك.

هل أحتاج إلى حساب AeroNyx لاستخدام الواجهة؟

لا. زوج مفاتيح Ed25519 هو الهوية. أنشئه محليًا ووقّع به كل طلب؛ والمفتاح العام هو عنوان الدردشة الخاص بك.

هل أوقّع /messages/ أم المسار الكامل؟

المسار الكامل كما أُرسل، بدءًا من /api/relay/developer/v1، مع سلسلة الاستعلام إن وُجدت. يؤدي توقيع /messages/ وحده إلى 401. ويوضح متجه اختبار المصادقة في هذه الصفحة المدخلات بالتحديد.

لماذا أتلقى 401؟

يعيد كل إخفاق في المصادقة الرمز 401 نفسه. والأسباب المعتادة هي ساعة منحرفة بأكثر من 60 ثانية، أو إعادة استخدام nonce، أو مسار أو جسم موقَّع يختلف عمّا أُرسل، أو طريقة HTTP بأحرف صغيرة في البيانات الموقَّعة. اتبع قائمة التحقق ضمن استكشاف أخطاء 401 وإصلاحها وقارن الملخص لديك بمتجه الاختبار.

لماذا تعيد الواجهة 202 لكن الرسالة لا تظهر أبدًا في AeroNyx App؟

يقبل المرحِّل أي حمولة Base64 صالحة، لكن التطبيق لا يعرض إلا مغلفًا مختومًا يستطيع التحقق منه وفك تشفيره. قارن payload_b64 لديك بالمتجه الذهبي في هذه الصفحة، وتأكد من أن receiver_pubkey في المغلف هو المستلم، وأبقِ الطابع الزمني للمغلف ضمن 300 ثانية من الوقت الحالي. إرسال نص عادي مرمَّز بـ Base64 يُقبل لكنه لا يُعرض أبدًا.

لماذا تعيد الواجهة invalid_request؟

أكثر الأسباب شيوعًا هي الإرسال إلى مفتاحك العام، أو جسم لا يحتوي على الحقول الخمسة الموثّقة بالضبط، أو message_id ليس UUID، أو discriminant خارج النطاق 1-255. راجع جدول الأخطاء ضمن الحدود والأخطاء.

هل تستطيع AeroNyx قراءة الرسائل المرسلة عبر هذه الواجهة؟

لا. تُشفَّر المغلفات بمفتاح مشتق من هويتَي المرسِل والمستلم، ولا تحتفظ AeroNyx به أبدًا. تستطيع الخدمة رؤية البيانات الوصفية للتسليم مثل المفاتيح العامة والأوقات والأحجام وعناوين IP.

إلى متى يُحتفظ بالرسائل غير المسلَّمة؟

حتى 72 ساعة. لا تؤكد استلام كل رسالة إلا بعد فك تشفيرها وحفظها؛ إذ يمكن تسليم الرسائل غير المؤكَّد استلامها مرة أخرى.

ما لغات البرمجة التي يمكنني استخدامها؟

أي لغة تدعم Ed25519 وX25519 وHKDF-SHA256 وXChaCha20-Poly1305، ويشمل ذلك روابط libsodium لمعظم اللغات. تتضمن هذه الصفحة تنفيذًا مرجعيًا كاملًا بلغة Python وبدءًا سريعًا قابلًا للتشغيل.

هل واجهة AeroNyx Chat API تعمل في الوقت الفعلي؟

الإرسال نعم: المستلم الذي يكون App لديه متصلًا يتلقى رسالتك فورًا. أما الاستلام عبر HTTPS فيعمل بالاستطلاع، لذا ترى الرسائل الجديدة عند استدعائك التالي لـ GET /inbox/. للاستلام في الوقت الفعلي، صِل الهوية نفسها بـ WebSocket الخاص بـ Chat Relay على wss://api.aeronyx.network/ws/relay/؛ فهو يقرأ قائمة الانتظار نفسها.

هل يتلقى مستخدم AeroNyx إشعارًا برسالة أرسلتُها؟

ليس حاليًا. الرسائل المرسلة عبر هذه الواجهة لا تُطلق إشعارًا فوريًا، لذا يرى المستخدم الذي يكون App لديه مغلقًا رسالتك عندما يفتحه في المرة التالية. أما المستخدم الذي يكون App لديه متصلًا فيراها فورًا.

<!-- faq:end -->