اعتماد بروتوكول Encrypted Client Hello (ECH)

‫Encrypted Client Hello ‏(ECH) هي إضافة إلى بروتوكول أمان طبقة النقل (TLS) تعمل على تشفير حقل "إشارة اسم الخادم" (SNI) في رسالة تأكيد الاتصال من العميل. في Android 17 (المستوى 37 من واجهة برمجة التطبيقات) والإصدارات الأحدث، تكون ميزة ECH مفعّلة تلقائيًا. تساعد ميزة ECH في الحفاظ على خصوصية زيارات المستخدمين على الويب من خلال منع الجهات الوسيطة على الشبكة من الاطّلاع على أسماء المضيفين التي يتصل بها التطبيق.

لمطوّري التطبيقات

لاستخدام ميزة ECH في تطبيقك، اتّبِع الخطوات التالية:

  1. تحقَّق من توفّر دعم ميزة ECH في مكتبة الشبكات: تأكَّد من أنّك تستخدم إصدارًا من المكتبة يتيح ميزة ECH على Android. سيتوفّر الدعم قريبًا في OkHttp وHttpEngine.
  2. اضبط إعداد أمان الشبكات: تكون ميزة ECH مفعّلة تلقائيًا لجميع النطاقات إذا كانت مكتبتك تتيحها. إذا كنت بحاجة إلى إيقاف ميزة ECH أو فرضها، اضبط العنصر domainEncryption في إعداد أمان الشبكات.
  3. عدِّل مستوى حزمة تطوير البرامج (SDK) المستهدَفة: لا تتوفّر ميزة ECH إلا على Android 17 (المستوى 37 من واجهة برمجة التطبيقات) والإصدارات الأحدث.

لمطوّري المكتبات

إذا كنت تطوّر مكتبة شبكات HTTP مخصّصة أو توسّع مكتبة حالية، عليك تنفيذ دعم ميزة ECH من خلال التفاعل مع واجهات برمجة التطبيقات الخاصة بالمنصّة.

التحقّق من سياسة تشفير النطاق

قبل طلب إعدادات ميزة ECH أو بدء الاتصالات، تحقَّق من سياسة تشفير النطاق الخاصة بالتطبيق من خلال استدعاء NetworkSecurityPolicy.getDomainEncryptionMode.

استنادًا إلى الوضع الذي يتم عرضه، تعامَل مع ميزة ECH على النحو التالي:

  • DOMAIN_ENCRYPTION_MODE_DISABLED و DOMAIN_ENCRYPTION_MODE_UNKNOWN: لا تجلب إعدادات ميزة ECH أو تحاول استخدامها.
  • DOMAIN_ENCRYPTION_MODE_ENABLED و DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC: افرض استخدام ميزة ECH. استرجِع إعدادات ميزة ECH واستخدِمها إذا كان الخادم يتيحها. إذا كان الخادم لا يتيح ميزة ECH، فعِّل ميزة ECH GREASE.

استرجاع إعدادات ميزة ECH

للاتصال باستخدام ميزة ECH، عليك حلّ سجلّ نظام أسماء النطاقات (DNS) الخاص ببروتوكول HTTPS للخادم الذي يحتوي على إعدادات ميزة ECH. عندما تستخدم التطبيقات نظام أسماء النطاقات (DNS) الخاص بالنظام، يمكن استرجاع هذه البيانات باستخدام إحدى الطريقتَين التاليتَين:

الطريقة 1: استخدام واجهة برمجة التطبيقات DnsResolver.query عالية المستوى

إذا كانت مكتبتك لا تتطلّب آليات مخصّصة لحلّ نظام أسماء النطاقات (DNS)، يمكنك استخدام واجهة برمجة التطبيقات DnsResolver.query عالية المستوى الخاصة بالمنصّة. تُجري واجهة برمجة التطبيقات هذه طلبات بحث متوازية عن سجلّات A وAAAA وHTTPS وتدمج النتائج في HttpsEndpoint.

Kotlin

val resolver = DnsResolver(context, looper)
resolver.query(network, hostname, DnsResolver.TYPE_HTTPS, executor,
    DnsResolver.HTTPS_QUERY_WAIT_AUTO, cancellationSignal,
    object : DnsResolver.Callback<HttpsEndpoint> {
        override fun onAnswer(answer: HttpsEndpoint, rcode: Int) {
            val record = answer.httpsRecords.firstOrNull() ?: return
            val echConfigList = record.echConfigList ?: return
            establishEchConnection(echConfigList)
        }
        override fun onError(error: DnsResolver.DnsException) { /* Handle error */ }
    })

Java

DnsResolver resolver = new DnsResolver(context, looper);
resolver.query(network, hostname, DnsResolver.TYPE_HTTPS, executor,
    DnsResolver.HTTPS_QUERY_WAIT_AUTO, cancellationSignal,
    new DnsResolver.Callback<HttpsEndpoint>() {
        @Override
        public void onAnswer(HttpsEndpoint answer, int rcode) {
            HttpsRecord record = answer.getHttpsRecords().stream().findFirst().orElse(null);
            if (record == null) return;
            EchConfigList echConfigList = record.getEchConfigList();
            if (echConfigList == null) return;
            establishEchConnection(echConfigList);
        }

        @Override
        public void onError(DnsResolver.DnsException error) { /* Handle error */ }
    });

الطريقة 2: استخدام getAllByName وDnsResolver.rawQuery

بالنسبة إلى المكتبات التي تدير اتصالات المقابس ومسارات حلّ نظام أسماء النطاقات (DNS)، قد تفضّل حلّ عناوين IP باستخدام واجهات برمجة التطبيقات العادية أثناء جلب سجلّ HTTPS بشكلٍ منفصل:

  1. يمكنك حلّ سجلّات A وAAAA باستخدام InetAddress.getAllByName للشبكة التلقائية أو Network.getAllByName.
  2. يمكنك استرجاع سجلّ HTTPS الأولي بالتوازي باستخدام DnsResolver.rawQuery. حدِّد DnsResolver.TYPE_HTTPS كنوع طلب البحث.
مسؤولية المطوّر والحالات القصوى

إذا اخترت الطريقة 2، تتحمّل مكتبتك مسؤوليات إضافية وعليك مراعاة الحالات القصوى.

  • تحليل سجلّ نظام أسماء النطاقات (DNS): عليك تحليل حمولة البايت الأولية لردّ نظام أسماء النطاقات (DNS) من rawQuery لاستخراج EchConfigList.
  • التعامل مع حالات عدم تطابق السجلّ: عليك التعامل مع حالات عدم التطابق بين طلبات البحث عن سجلّات A وAAAA وHTTPS.
  • حالات التنافس: عليك مزامنة نتائج عمليات البحث المتوازية عن نظام أسماء النطاقات (DNS) . إذا تم حلّ أحد طلبات البحث قبل الآخر أو إذا انتهت مهلة طلب البحث عن سجلّ HTTPS، عليك الرجوع إلى الإعدادات المناسبة (على سبيل المثال، من خلال محاولة إجراء اتصال عادي باستخدام بروتوكول أمان طبقة النقل (TLS) بدون ميزة ECH إذا تعذّر طلب البحث عن سجلّ HTTPS، أو باستخدام ميزة ECH GREASE إذا كانت مفعّلة بموجب السياسة).

ضبط بروتوكول أمان طبقة النقل (TLS)

بعد أن تسترجع المكتبة قائمة إعدادات ميزة ECH (EchConfigList) من HttpsRecord، مرِّر هذه القائمة باستخدام واجهات برمجة التطبيقات المساعدة SSLSockets أو SSLEngines قبل بدء تأكيد اتصال بروتوكول أمان طبقة النقل (TLS) .

Kotlin

fun establishEchConnection(echConfigList: EchConfigList) {
    val socket = sslSocketFactory.createSocket(ipAddress, port) as SSLSocket
    SSLSockets.setEchConfigList(socket, echConfigList)
    socket.startHandshake()
}

Java

public void establishEchConnection(EchConfigList echConfigList)
    throws IOException {
    SSLSocket socket =
        (SSLSocket) sslSocketFactory.createSocket(ipAddress, port);
    SSLSockets.setEchConfigList(socket, echConfigList);
    socket.startHandshake();
}

التعامل مع مسار إعادة المحاولة

إذا لم تتزامن إعدادات ميزة ECH الخاصة بالخادم، سيفشل تأكيد الاتصال مع ظهور EchConfigMismatchException (فئة فرعية من javax.net.ssl.SSLException). قد يتضمّن الخادم إعدادات ميزة ECH المعدَّلة في الرفض، والتي يجب استخدامها لإنشاء اتصال جديد. إذا لم تتم محاولة إعادة الاتصال على الرغم من أنّ الخادم يقدّم إعدادات صالحة لإعادة المحاولة، على المكتبة الإبلاغ عن خطأ للتطبيق الذي استدعاها.

للتعامل مع عمليات إعادة المحاولة باستخدام ميزة ECH، عليك رصد الاستثناء واتّباع الخطوات التالية:

  1. استدعِ EchConfigMismatchException.getPublicHostname على الـ استثناء.
  2. تحقَّق من اسم المضيف العلني الذي تم عرضه باستخدام HostnameVerifier. إذا كان null، أوقِف الاتصال.
  3. إذا نجح التحقّق من اسم المضيف، ابحث عن الإعدادات المعدَّلة باستخدام EchConfigMismatchException.getRetryConfigList.
  4. إذا كانت الإعدادات المعدَّلة متاحة، أعِد محاولة الاتصال باستخدام الـ EchConfigList الجديد.

Kotlin

try {
    socket.startHandshake()
} catch (e: EchConfigMismatchException) {
    val publicName = e.publicHostname ?: throw e
    if (hostnameVerifier.verify(publicName, socket.session)) {
        val retryConfigList = e.retryConfigList
        if (retryConfigList != null) {
            retryConnection(retryConfigList)
        }
    } else {
        throw e // Hostname mismatch
    }
}

Java

try {
    socket.startHandshake();
} catch (EchConfigMismatchException e) {
    String publicName = e.getPublicHostname();
    if (publicName == null) {
        throw e;
    }
    if (hostnameVerifier.verify(publicName, socket.getSession())) {
        EchConfigList retryConfigList = e.getRetryConfigList();
        if (retryConfigList != null) {
            retryConnection(retryConfigList);
        }
    } else {
        throw e; // Hostname mismatch
    }
}

يمكنك الاطّلاع على مزيد من التفاصيل حول مسار إعادة المحاولة في RFC 9849، ولا سيما سبب ضرورة المصادقة على الاسم العلني.