การใช้ ClientHello ที่เข้ารหัส (ECH)

ClientHello ที่เข้ารหัส (ECH) คือส่วนขยาย TLS ที่เข้ารหัสช่องการระบุชื่อเซิร์ฟเวอร์ (SNI) ในข้อความ Handshake ของไคลเอ็นต์ ใน Android 17 (API ระดับ 37) ขึ้นไป ระบบจะรองรับ ECH โดยค่าเริ่มต้น ECH ช่วยรักษาความเป็นส่วนตัวของการเข้าชมเว็บของผู้ใช้ด้วยการป้องกันไม่ให้ตัวกลางในเครือข่ายเห็นชื่อโฮสต์ที่แอปเชื่อมต่อ

สำหรับนักพัฒนาแอป

วิธีใช้ ECH ในแอปพลิเคชัน

  1. ตรวจสอบว่าไลบรารีเครือข่ายรองรับ ECH หรือไม่: ตรวจสอบว่าคุณใช้ ไลบรารีเวอร์ชันที่รองรับ ECH ใน Android โดยจะรองรับใน OkHttp และ HttpEngine เร็วๆ นี้
  2. กำหนดค่าการกำหนดค่าความปลอดภัยของเครือข่าย: โดยค่าเริ่มต้น ระบบจะเปิดใช้ ECH สำหรับทุก โดเมนหากไลบรารีของคุณรองรับ หากต้องการปิดใช้หรือบังคับใช้ ECH, ให้กำหนดค่าองค์ประกอบ domainEncryption ใน การกำหนดค่าความปลอดภัยของเครือข่าย
  3. อัปเดตระดับ SDK เป้าหมาย: ECH ใช้ได้ใน Android 17 (API ระดับ 37) ขึ้นไปเท่านั้น

สำหรับนักพัฒนาไลบรารี

หากคุณกำลังพัฒนาไลบรารีเครือข่าย HTTP ที่กำหนดเองหรือขยายไลบรารีที่มีอยู่ คุณควรใช้การรองรับ ECH โดยโต้ตอบกับ API ของแพลตฟอร์ม

ตรวจสอบนโยบายการเข้ารหัสโดเมน

ก่อนที่จะค้นหาการกำหนดค่า ECH หรือเริ่มต้นการเชื่อมต่อ ให้ตรวจสอบ นโยบายการเข้ารหัสโดเมนของแอปโดยเรียก NetworkSecurityPolicy.getDomainEncryptionMode

จัดการ ECH ดังนี้โดยขึ้นอยู่กับโหมดที่แสดงผล

  • DOMAIN_ENCRYPTION_MODE_DISABLED และ DOMAIN_ENCRYPTION_MODE_UNKNOWN: ไม่ต้องดึงข้อมูลการกำหนดค่า ECH หรือลองใช้ ECH
  • DOMAIN_ENCRYPTION_MODE_ENABLED และ DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC: บังคับใช้ ECH ดึงข้อมูลการกำหนดค่า ECH และใช้ ECH หากเซิร์ฟเวอร์รองรับ หากเซิร์ฟเวอร์ไม่รองรับ ECH ให้เปิดใช้ ECH GREASE

ดึงข้อมูลการกำหนดค่า ECH

หากต้องการเชื่อมต่อกับ ECH คุณต้องจับคู่ข้อมูลระเบียน DNS HTTPS ของเซิร์ฟเวอร์ที่มีการกำหนดค่า ECH เมื่อแอปใช้ DNS ของระบบ คุณจะดึงข้อมูลนี้ได้โดยใช้วิธีใดวิธีหนึ่งต่อไปนี้

วิธีที่ 1: ใช้ API DnsResolver.query ระดับสูง

หากไลบรารีไม่จำเป็นต้องมีกลไกการแปลง DNS ที่กำหนดเอง คุณสามารถใช้ API DnsResolver.query ระดับสูงของแพลตฟอร์มได้ API นี้จะทำการค้นหาแบบขนาน สำหรับระเบียน 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 โดยใช้ API มาตรฐานขณะดึงข้อมูลระเบียน 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 แล้ว ให้ส่งรายการนี้โดยใช้ API ยูทิลิตี SSLSockets หรือ SSLEngines ก่อนที่จะเริ่ม Handshake 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 ของเซิร์ฟเวอร์ไม่ซิงค์กัน Handshake จะล้มเหลวโดยมี 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 โดยเฉพาะ เหตุผลที่ ต้องมีการตรวจสอบสิทธิ์สำหรับชื่อสาธารณะ