פרוטוקול הצפנת ClientHello (ECH) הוא תוסף ל-TLS (אבטחת שכבת התעבורה), שמשמש להצפנת השדה Server Name Indication (SNI) בהודעת הלחיצה של הלקוח. ב-Android מגרסה 17 (רמת API 37) ואילך, ECH נתמך כברירת מחדל. הצפנת חיבורים לשרתים עוזרת לשמור על פרטיות התנועה באינטרנט של המשתמשים, כי היא מונעת מגורמים מתווכים ברשת לראות את שמות המארחים שאליהם מתחברת אפליקציה.
למפתחי אפליקציות
כדי להטמיע ECH באפליקציה:
- בודקים אם ספריית הרשת תומכת ב-ECH: מוודאים שאתם משתמשים בגרסת ספרייה שתומכת ב-ECH ב-Android. בקרוב תהיה תמיכה ב-OkHttp וב-HttpEngine.
- הגדרת תצורה של אבטחת רשת: כברירת מחדל, ECH מופעל לכל הדומיינים אם הספרייה שלכם תומכת בו. אם אתם צריכים להשבית או לאכוף ECH, אתם יכולים להגדיר את רכיב
domainEncryptionבתצורה של אבטחת רשת. - מעדכנים את רמת ה-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:
- פתרון רשומות A/AAAA באמצעות
InetAddress.getAllByNameעבור רשת ברירת המחדל אוNetwork.getAllByName. - אחזור רשומת ה-HTTPS הגולמית במקביל באמצעות
DnsResolver.rawQuery. מצייניםDnsResolver.TYPE_HTTPSכסוג השאילתה.
האחריות של המפתחים ומקרים חריגים
אם בוחרים בשיטה 2, יש בספרייה עוד אחריות ומקרים מיוחדים שצריך לקחת בחשבון.
- ניתוח של רשומת DNS: צריך לנתח את מטען המידע (payload) של בייטים גולמיים בתגובת ה-DNS מ-
rawQueryכדי לחלץ אתEchConfigList. - טיפול באי-התאמות ברשומות: צריך לטפל בחוסר עקביות בין שאילתות A/AAAA לבין שאילתות HTTPS.
- תנאי מירוץ: צריך לסנכרן את התוצאות של בדיקות ה-DNS המקבילות. אם שאילתה אחת נפתרת לפני השנייה או אם חלף הזמן הקצוב לתפוגה של שאילתת ה-HTTPS, צריך לחזור אחורה בצורה מתאימה (לדוגמה, על ידי ניסיון ליצור חיבור TLS רגיל ללא ECH אם שאילתת ה-HTTPS נכשלת, או על ידי שימוש ב-ECH GREASE אם הוא מופעל על ידי מדיניות).
הגדרת TLS
אחרי שהספרייה מאחזרת את רשימת ההגדרות של ECH (EchConfigList) מ-HttpsRecord, מעבירים את הרשימה הזו באמצעות ממשקי ה-API של כלי השירות 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, צריך ללכוד את החריגה ולבצע את השלבים הבאים:
- שיחה למספר
EchConfigMismatchException.getPublicHostnameבחריגה. - מאמתים את שם המארח הציבורי שהוחזר באמצעות
HostnameVerifier. אם הואnull, מבטלים את החיבור. - אם אימות שם המארח מצליח, בודקים אם יש הגדרות מעודכנות באמצעות
EchConfigMismatchException.getRetryConfigList. - אם יש הגדרות מעודכנות, מנסים שוב להתחבר עם
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, ובמיוחד הסבר למה צריך לאמת את השם הציבורי.