זהות והצפנת תעבורה¶
נניח שהתחברנו לכתובת שהresolver החזיר, והשרת ענה בדיוק לפי הפרוטוקול. עדיין לא הוכחנו שזה השרת שאליו התכוונו. מישהו יכול להפעיל שירות שמדבר באותו פורמט, או שהשם יכול להיפתר לכתובת שלא ציפינו לה. גם הצפנת המידע אינה מספיקה אם הצפנו אותו לצד הלא נכון.
נשתמש בTLS דרך Schannel, ספק האבטחה של ווינדוס. אנחנו לא נכתוב אלגוריתם הצפנה. כן נכתוב את מכונת המצבים שמחברת בין הsocket לבין מנגנון האבטחה, ונבין מי מחזיק כל buffer, מתי זהות השרת נבדקה ומה נחשב סיום תקין של השיחה.
שלוש שכבות של מסגור¶
בזרם TCP אין גבולות הודעה. מעליו TLS מחלק את התעבורה לrecords, ומעל המידע המפוענח הפרוטוקול שלנו עדיין מחלק הודעות עסקיות. קריאת recv אחת יכולה להכיל חלק מrecord, record שלם או כמה records. גם record מפוענח אינו חייב להכיל הודעה עסקית שלמה.
לכן נצטרך מצב עבור bytes מוצפנים שנשארו, מצב עבור מנוע TLS ומצב עבור parser היישום. אם נערבב את שלושתם באותו offset בלי להגדיר למי הוא שייך, נאבד מידע ברגע שבו קריאה אחת תחזיר יותר ממה שהשלב הנוכחי צרך.
אישור שימוש ומצב שיחה¶
הhandle מסוג credential מתאר את יכולת התהליך להשתמש בספק עם הגדרות מסוימות. הcontext הוא מצב של שיחה מסוימת. אפשר לחשוב על הראשון כעל תצורה והרשאה לשימוש במנגנון, ועל השני כעל המשא ומתן והסודות של החיבור. לכל אחד פונקציית שחרור אחרת.
בצד הלקוח נבחר אימות תעודה אוטומטי, שם יעד מקורי ובדיקת revocation לפי מדיניות מפורשת. לא נפעיל דגל שמבטל בדיקת שם כדי ששרת מעבדה עם תעודה לא מתאימה "יעבוד". ההגדרות של SCH_CREDENTIALS מפרידות בין הבחירות האלה ובין שליחה אוטומטית של תעודת לקוח, שאותה לא נרצה בדוגמה.
צעד אחד בhandshake¶
נבנה מחלקה שמבצעת צעד אחד בכל פעם, בלי להסתיר קריאות רשת בתוכה. כך אפשר לחבר אותה גם לשרת חוסם וגם לIOCP. הקלט הוא רצף bytes מוצפנים שנמצא בבעלות הקורא; הפלט מציין כמה ממנו נצרך ואילו bytes צריך לשלוח לצד השני.
שמרו את הקוד בtls_handshake.h. הוא מיועד לWindows SDK שתומך בSCH_CREDENTIALS, בהתאם לבסיס Windows 11 של הקורס. סדר הheaders וההגדרה של SCHANNEL_USE_BLACKLISTS מאפשרים להשתמש במבנה הזה.
#pragma once
#define SECURITY_WIN32
#define SCHANNEL_USE_BLACKLISTS
#include <windows.h>
#include <winternl.h>
#include <security.h>
#include <schannel.h>
#include <cstddef>
#include <span>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#pragma comment(lib, "secur32.lib")
enum class tls_progress { more_input, exchange, ready };
struct tls_step {
tls_progress progress;
std::size_t consumed;
std::vector<std::byte> output;
};
class tls_handshake {
CredHandle credential_{};
CtxtHandle context_{};
std::wstring target_;
bool first_ = true;
bool ready_ = false;
struct output_owner {
SecBuffer buffer{0, SECBUFFER_TOKEN, nullptr};
~output_owner() { if (buffer.pvBuffer) FreeContextBuffer(buffer.pvBuffer); }
};
[[noreturn]] static void fail(SECURITY_STATUS status) {
throw std::runtime_error("Schannel status=" +
std::to_string(static_cast<unsigned long>(status)));
}
public:
explicit tls_handshake(std::wstring target) : target_(std::move(target)) {
if (target_.empty()) throw std::invalid_argument("empty TLS target");
SecInvalidateHandle(&credential_);
SecInvalidateHandle(&context_);
SCH_CREDENTIALS config{};
config.dwVersion = SCH_CREDENTIALS_VERSION;
config.dwFlags = SCH_CRED_AUTO_CRED_VALIDATION |
SCH_CRED_NO_DEFAULT_CREDS |
SCH_CRED_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT |
SCH_USE_STRONG_CRYPTO;
TimeStamp expiry{};
SECURITY_STATUS status = AcquireCredentialsHandleW(
nullptr, const_cast<wchar_t*>(UNISP_NAME_W), SECPKG_CRED_OUTBOUND,
nullptr, &config, nullptr, nullptr, &credential_, &expiry);
if (status != SEC_E_OK) fail(status);
}
tls_handshake(const tls_handshake&) = delete;
tls_handshake& operator=(const tls_handshake&) = delete;
~tls_handshake() {
if (SecIsValidHandle(&context_)) DeleteSecurityContext(&context_);
if (SecIsValidHandle(&credential_)) FreeCredentialsHandle(&credential_);
}
tls_step advance(std::span<std::byte> input) {
if (ready_) throw std::logic_error("initial handshake already complete");
if (input.size() > 256 * 1024) throw std::runtime_error("handshake input limit");
if (first_ && !input.empty()) throw std::logic_error("first step takes no input");
if (!first_ && input.empty()) return {tls_progress::more_input, 0, {}};
SecBuffer in[2] = {
{static_cast<ULONG>(input.size()), SECBUFFER_TOKEN, input.data()},
{0, SECBUFFER_EMPTY, nullptr}
};
SecBufferDesc input_desc{SECBUFFER_VERSION, 2, in};
output_owner output;
SecBufferDesc output_desc{SECBUFFER_VERSION, 1, &output.buffer};
ULONG attributes = 0;
TimeStamp expiry{};
constexpr ULONG requested = ISC_REQ_STREAM | ISC_REQ_CONFIDENTIALITY |
ISC_REQ_REPLAY_DETECT | ISC_REQ_SEQUENCE_DETECT | ISC_REQ_ALLOCATE_MEMORY;
SECURITY_STATUS status = InitializeSecurityContextW(
&credential_, first_ ? nullptr : &context_, target_.data(), requested,
0, SECURITY_NATIVE_DREP, first_ ? nullptr : &input_desc, 0,
&context_, &output_desc, &attributes, &expiry);
if (status == SEC_E_INCOMPLETE_MESSAGE)
return {tls_progress::more_input, 0, {}};
if (status != SEC_I_CONTINUE_NEEDED && status != SEC_E_OK) fail(status);
first_ = false;
std::size_t extra = in[1].BufferType == SECBUFFER_EXTRA ? in[1].cbBuffer : 0;
if (extra > input.size()) throw std::runtime_error("invalid extra count");
tls_step result{tls_progress::exchange, input.size() - extra, {}};
if (output.buffer.cbBuffer) {
auto begin = static_cast<const std::byte*>(output.buffer.pvBuffer);
result.output.assign(begin, begin + output.buffer.cbBuffer);
}
if (status == SEC_E_OK) {
if ((attributes & ISC_RET_CONFIDENTIALITY) == 0 ||
(attributes & ISC_RET_STREAM) == 0)
throw std::runtime_error("required TLS attributes missing");
ready_ = true;
result.progress = tls_progress::ready;
}
return result;
}
CtxtHandle* context() {
if (!ready_) throw std::logic_error("TLS is not ready");
return &context_;
}
};
המחלקה מטפלת בhandshake ראשוני של לקוח ללא תעודת לקוח. היא דוחה מצבים שאינם נתמכים בה, במקום להחזיר ready למרות שהאימות לא הסתיים. היא אינה wrapper מלא לTLS: צריך לחבר אליה תעבורה, records של נתונים וסיום שיחה. ההפרדה הזאת מאפשרת לנו לבדוק כל מעבר בנפרד.
כדי לבנות בדיקת יצירה וצעד ראשון, שמרו את הקוד הבא בfirst_token.cpp. הניסוי לא מתחבר לרשת ולא מוכיח זהות של שרת; הוא רק יוצר את הtoken הראשון ואת חיי הhandles המקומיים.
#include "tls_handshake.h"
#include <iostream>
int main() {
try {
tls_handshake tls(L"example.com");
auto step = tls.advance({});
std::cout << "output bytes=" << step.output.size()
<< " ready=" << (step.progress == tls_progress::ready) << '\n';
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
בנו עם הSDK הנעול של הקורס.
מי שומר את הbytes שלא נצרכו¶
הקורא מפעיל תחילה advance עם קלט ריק ושולח את הoutput. לאחר מכן הוא מצרף bytes שהתקבלו לbuffer מצטבר וקורא שוב. כאשר התוצאה היא more_input, הוא שומר את כל הקלט ומוסיף עוד. כאשר יש consumed, הוא מסיר רק את הקידומת הזאת ומשאיר את היתרה.
במצב ready, היתרה עשויה להיות כבר נתונים מוצפנים של האפליקציה שהגיעו יחד עם סוף הhandshake. אסור לזרוק אותה. ההבחנה בין מידע חסר למידע נוסף מפורטת בחוזה הbuffers הנוספים של Schannel.
הoutput מועתק לvector שלנו לפני שחרור הזיכרון שספק האבטחה הקצה. אם נגיש אותו לWSASend אסינכרוני, הvector יישאר בבעלות פעולת השליחה עד completion. אין קשר בין חיי ההקצאה המקורית של SSPI לבין חיי ההעתקה שעכשיו משמשת את הרשת.
מתי מותר להאמין לזהות¶
הסטטוס SEC_I_CONTINUE_NEEDED אינו אישור לשליחת סודות של היישום. גם העובדה שכבר החלפנו bytes שנראים אקראיים אינה אישור. נמתין להשלמה, נבדוק את הattributes שנדרשו ונשמור על אימות התעודה ושם היעד. התפקיד של השם ושל תוצאות ההמשך מוגדר בInitializeSecurityContext של Schannel.
את שם היעד ניקח מהתצורה שהמשתמש ביקש או מהמדיניות שלנו, ולא משדה שהשרת החזיר. אם התחברנו לכתובת IPv4 אחרי fallback, עדיין נאמת את אותו שם DNS. כאשר עובדים מול תעודות מעבדה, נשתמש ברשות מעבדה ובהגדרות אמון מתועדות במכונת היעד; בדיקת תעודה פגומה חייבת להיכשל.
כשל revocation בגלל סביבה לא זמינה אינו שקול לתעודה תקינה. נחליט במדיניות איך מדווחים ומנהלים את המצב, בלי להוסיף דגל ignore אוטומטי בכל פעם שהרשת לא נוחה. גם בדיקת תעודה יכולה להפעיל עבודה של המערכת מעבר לקריאת socket אחת, ולכן deadline של transport אינו בהכרח ביטול מיידי של כל קריאת SSPI.
העברת נתונים וסיום¶
לאחר handshake נשאל את SECPKG_ATTR_STREAM_SIZES. נקצה מקום לheader, מידע וtrailer לפי הגדלים שהספק החזיר, ונפעיל EncryptMessage. נשלח רק את הbuffers והגדלים שהוחזרו, ונשמור סדר בין פעולות הצפנה של אותו context. הפרטים נמצאים בחוזה ההצפנה.
בפענוח נמסור buffer מוצפן לDecryptMessage. הפונקציה יכולה לפענח במקום, ולכן plaintext שמוחזר עשוי להצביע לתוך אותו buffer. לא נזיז אותו בזמן שהparser משתמש בתצוגה לתוכו. נפריד בין מידע חסר, מידע נוסף, בקשת המשך handshake וסימון סיום TLS. אפשר לבדוק את חוזה הפענוח.
סוף TCP אינו תמיד סיום TLS מאומת. אם הזרם נסגר באמצע record, לא נציג ליישום תוצאה מלאה רק מפני שrecv החזיר אפס. סיום תקין כולל את מנגנון הסגירה של TLS, ומעליו עדיין נצטרך לדעת שהודעת היישום הושלמה.
אימות והרשאה¶
הממשק SSPI משמש גם ספקי אימות אחרים, למשל תרחישי Negotiate בארגון. שם נפגוש שוב tokens של פרוטוקול וsecurity contexts, אבל לא נניח שהflags והמשמעויות זהים לאלו של Schannel. בפרט, אימות peer אינו החלטה שהוא רשאי למחוק כל עבודה במוצר.
אחרי שהזהות נבדקה, נתרגם אותה למודל ההרשאות של היישום ונבדוק כל פעולה. נבדיל גם בין token של חילופי פרוטוקול לבין access token של ווינדוס. אותה מילה מופיעה בשני המקומות, אך האובייקטים, הבעלות והמשמעות שלהם שונים.