לדלג לתוכן

9.7 - פתרון שמות וחיבור מוצפן - הרצאה

שלוש פעולות שמתחבאות מאחורי כתובת אחת

כאשר משתמש מזין שם שירות, היישום צריך בדרך כלל לפתור את השם לכתובות, לפתוח חיבור ולבצע אימות של ערוץ מוצפן. שלושת השלבים יכולים להיכשל מסיבות שונות. אם כולן נרשמות ביומן כשגיאת רשת, קשה לדעת אם צריך לבדוק הגדרת שם, נתיב רשת או תעודה.

נפריד גם את המדידות. זמן פתרון שם אינו זמן לחיצת היד של התעבורה, וזמן לחיצת היד המוצפנת אינו זמן עיבוד הבקשה. הפרדה כזאת מאפשרת לזהות שינוי בסביבה בלי להאשים מיד את הקוד העסקי. היא גם מונעת מצב שבו כל שלב מתחיל מחדש מגבלת זמן מלאה ויוצר המתנה ארוכה בהרבה ממה שהמשתמש ביקש.

פתרון שם אינו בהכרח שאילתת רשת אחת

הממשק getaddrinfo מחזיר כתובת אחת או יותר לפי שם, שירות והגבלות משפחת כתובות. בלינוקס הדרך לפתרון יכולה להיות מושפעת ממקורות מקומיים, מהגדרת ספריית המערכת ומשירותי פתרון נוספים. לכן לא כל עיכוב בשלב הזה נובע מחבילת רשת לשרת שמות חיצוני.

שם יכול להחזיר גם כתובות בגרסה רביעית וגם בגרסה שישית. ניסיון רק לכתובת הראשונה עלול להיכשל למרות שאחרת נגישה. מצד שני, ניסיון סדרתי עם מגבלת זמן מלאה לכל כתובת יכול ליצור זנב ארוך. במערכות ייצור משתמשים במדיניות ניסיונות מתאימה, לעיתים חופפת, תוך הגבלת מספר החיבורים הפעילים ושמירת מועד כולל.

זיכרון מטמון של שמות משנה גם הוא את המדידה. בקשה אחת יכולה להשתמש בתוצאה קיימת ואחרת להגיע למקור חיצוני. יש לתעד אם הניסוי בודק מצב חם או קר, ולא להציג מדידה בודדת כעלות קבועה של פתרון שם.

שם השרת אינו רק הכתובת שאליה התחברנו

האימות המוצפן בודק בין היתר שהשרת מציג שרשרת אמון מתאימה ושזהותו מתאימה לשם שהלקוח התכוון אליו. כתובת החיבור יכולה להגיע מרשימת כתובות, אבל שם האימות נשאר חלק מכוונת הלקוח. החלפתו בשם אחר רק כדי לגרום לבדיקה לעבור מבטלת את משמעות האימות.

השם שנשלח במהלך לחיצת היד יכול גם לעזור לשרת לבחור תצורה או תעודה מתאימה, אך בחירת התעודה ואימותה הן פעולות שונות. שרת יכול להציג תעודה, והלקוח עדיין חייב לבדוק שהיא מתאימה לחוזה האמון שלו. אין להחליף את המאמת במימוש שמאשר כל תעודה כדי לפתור תקלה במעבדה.

סביבת אמון מקומית לניסוי

ניצור רשות מקומית ותעודת שרת לשם localhost, בלי להתקין את הרשות במאגר האמון של מערכת ההפעלה. הלקוח יקרא את קובץ הרשות במפורש. הפקודות הבאות מיועדות לתיקיית מעבדה חדשה עם כלי OpenSSL מגרסה שלישית.

openssl req -x509 -newkey rsa:2048 -noenc -keyout ca.key -out ca.crt -days 1 -subj "/CN=AmitTech Lab CA" -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
openssl req -newkey rsa:2048 -noenc -keyout server.key -out server.csr -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost" -addext "basicConstraints=critical,CA:FALSE"
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 1 -copy_extensions copy
openssl s_server -accept 127.0.0.1:8443 -cert server.crt -key server.key -www -tls1_3

הפקודה האחרונה מפעילה שרת מעבדה ונשארת פעילה עד שעוצרים אותו. המפתחות נוצרו רק לצורך הניסוי, והאמון בהם מוגבל ללקוח שמוגדר בהמשך. תיעוד פקודת החתימה מסביר את העתקת ההרחבות שבה השתמשנו.

לקוח שמפריד בין שלבים

נשתמש בספריית הצפנה מוכרת ונשאיר את מימוש האלגוריתמים והאימות בתוכה. הממשק הנמוך מאפשר לראות מתי יש בתים מוצפנים לכתיבה ומתי צריך לקרוא עוד בתים לפני התקדמות. הגדרת החבילה הבאה מקבעת את גרסת ספריית הפרוטוקול שנבדקה בתיעוד המעבדה.

[package]
name = "tls_stages"
version = "0.1.0"
edition = "2024"

[dependencies]
rustls = { version = "=0.23.46", default-features = false, features = ["std", "ring", "tls12"] }
rustls-pemfile = "2"

הקוד הבא בודק לחיצת יד בלבד. הוא אינו שולח בקשה עסקית, ולכן הצלחתו מעידה על אימות הערוץ ולא על תפקוד יישום השרת.

use std::fs::File;
use std::io::{self, BufReader};
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::Arc;
use std::time::{Duration, Instant};
use rustls::{ClientConfig, ClientConnection, RootCertStore};
use rustls::pki_types::ServerName;

fn remaining(deadline: Instant) -> io::Result<Duration> {
    deadline.checked_duration_since(Instant::now())
        .filter(|duration| !duration.is_zero())
        .ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "connection budget exhausted"))
}

fn flush_tls(connection: &mut ClientConnection, socket: &mut TcpStream,
             deadline: Instant) -> io::Result<()> {
    while connection.wants_write() {
        socket.set_write_timeout(Some(remaining(deadline)?))?;
        if connection.write_tls(socket)? == 0 {
            return Err(io::Error::new(io::ErrorKind::WriteZero, "TLS output stalled"));
        }
    }
    Ok(())
}

fn main() -> io::Result<()> {
    let expected_name = std::env::args().nth(1).unwrap_or_else(|| "localhost".into());
    let ca_path = std::env::args().nth(2).unwrap_or_else(|| "ca.crt".into());
    let mut roots = RootCertStore::empty();
    let mut certificates = BufReader::new(File::open(ca_path)?);
    for certificate in rustls_pemfile::certs(&mut certificates) {
        roots.add(certificate?).map_err(io::Error::other)?;
    }
    let config = ClientConfig::builder().with_root_certificates(roots).with_no_client_auth();
    let name = ServerName::try_from(expected_name).map_err(io::Error::other)?;
    let started = Instant::now();
    let addresses: Vec<_> = ("localhost", 8443).to_socket_addrs()
        .map_err(|error| io::Error::other(format!("resolve: {error}")))?.collect();
    eprintln!("resolve elapsed: {:?}", started.elapsed());
    let deadline = Instant::now() + Duration::from_secs(3);
    let mut connected = None;
    let mut last_error = io::Error::new(io::ErrorKind::NotFound, "no addresses");
    for address in addresses {
        match TcpStream::connect_timeout(&address, remaining(deadline)?) {
            Ok(socket) => { connected = Some(socket); break; }
            Err(error) => last_error = error,
        }
    }
    let mut socket = connected.ok_or_else(|| io::Error::other(format!("connect: {last_error}")))?;
    let mut connection = ClientConnection::new(Arc::new(config), name).map_err(io::Error::other)?;
    while connection.is_handshaking() {
        flush_tls(&mut connection, &mut socket, deadline)?;
        if !connection.is_handshaking() { break; }
        socket.set_read_timeout(Some(remaining(deadline)?))?;
        if connection.read_tls(&mut socket)? == 0 {
            return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "TLS handshake truncated"));
        }
        connection.process_new_packets()
            .map_err(|error| io::Error::other(format!("TLS verification/protocol: {error}")))?;
    }
    flush_tls(&mut connection, &mut socket, deadline)?;
    println!("verified protocol: {:?}", connection.protocol_version());
    connection.send_close_notify();
    flush_tls(&mut connection, &mut socket, deadline)?;
    Ok(())
}

הרצה עם השם localhost ועם הרשות שנוצרה אמורה להצליח בסביבת המעבדה. הרצה עם שם אחר אמורה להיכשל באימות גם אם החיבור לאותה כתובת מצליח. הפלט המספרי של המדידות תלוי בסביבה ואינו מוגדר מראש.

גבולות הזמן של הדוגמה

הקוד מגביל יחד את ניסיונות החיבור ואת לחיצת היד, אך מודד את פתרון השם הסינכרוני בנפרד. הוא אינו טוען שמגבלת שלוש השניות כוללת גם את משך הקריאה החוסמת לפתרון שם. כדי להציע מועד כולל באמת צריך מנגנון פתרון מתאים או בידוד של העבודה החוסמת, יחד עם הבנה שביטול הממתין אינו בהכרח מפסיק את פעולת הפתרון שכבר התחילה.

גם זמני ההמתנה של הסוקט אינם הבטחה של מערכת זמן אמת. הם מגבילים את ההמתנות של הממשק, אך התהליך עדיין תלוי בתזמון מערכת ההפעלה. בדוח נבדיל בין תקציב שהוגדר לבין זמן שנמדד בפועל תחת עומס.

מה מודדים ומה שומרים ביומן

נרשום את שלב הכשל, הכתובת שנוסתה, משפחת הכתובת ומשך השלב. לא נרשום מפתחות פרטיים או תוכן רגיש של בקשות כדי לאבחן בעיית תעודה. כאשר משווים עלות של חיבור חדש לחיבור חוזר, נציין אם נעשה שימוש בחידוש מפגש, משום שהוא משנה את העבודה הנדרשת.

העברת נתונים מוקדמים יכולה לשנות גם את חוזה החזרה והכפילות של הפרוטוקול. לכן אין להפעיל אותה אוטומטית עבור פעולה שאינה בטוחה לחזרה רק כדי לחסוך זמן לחיצת יד. בספרייה ובפרוטוקול צריך לבדוק את ההבטחות המתאימות לפני שילוב תכונה כזאת.

התיעוד של פתרון כתובות ושל מנוע החיבור המוצפן מגדיר את הממשקים. הדוגמה שומרת את שאלת ההצפנה בתוך ספרייה ייעודית ואת שאלת השלבים והזמנים בתוך קוד שאפשר לחקור.