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

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

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

หากต้องการใช้ ECH ในแอปพลิเคชัน ให้ทำดังนี้

  1. ตรวจสอบไลบรารีเครือข่ายว่ารองรับ ECH หรือไม่: ตรวจสอบว่าคุณใช้ไลบรารีเวอร์ชันที่รองรับ ECH ใน Android ดังนี้
    • OkHttp: ตั้งแต่ OkHttp 5.5.0 เป็นต้นไป คุณจะเปิดใช้การรองรับ ECH ได้โดย กำหนดค่า AndroidDns หรือ DnsOverHttps ใน OkHttpClient.Builder ดูข้อมูลเพิ่มเติมได้ที่บันทึกการเปลี่ยนแปลง
    • HttpEngine: จะรองรับใน Android 17 QPR2 (ระดับ API 37.2) คุณไม่จำเป็นต้องกำหนดค่าพิเศษใดๆ
    • WebView: เราจะเพิ่มการรองรับในรุ่นต่อๆ ไป
  2. กำหนดค่ากำหนดความปลอดภัยของเครือข่าย: โดยค่าเริ่มต้น ECH จะเปิดใช้สำหรับโดเมนทั้งหมด หากไลบรารีรองรับ หากต้องการปิดใช้หรือบังคับใช้ ECH ให้ กำหนดค่าองค์ประกอบ domainEncryption ใน Network Security Config
  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: ใช้ DnsResolver.query API ระดับสูง

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