לדלג לתוכן

שמירת סודות

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

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

הגנה שקשורה לזהות - DPAPI

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

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

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

שמרו את הקוד הבא בקובץ secret.cpp.

#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <wincrypt.h>
#include <cstring>
#include <fstream>
#include <iostream>
#include <iterator>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>

#pragma comment(lib, "crypt32.lib")

struct local_blob {
    DATA_BLOB value{};
    local_blob() = default;
    local_blob(const local_blob&) = delete;
    local_blob& operator=(const local_blob&) = delete;
    ~local_blob() {
        if (value.pbData) {
            SecureZeroMemory(value.pbData, value.cbData);
            LocalFree(value.pbData);
        }
    }
};

[[noreturn]] void fail(const char* operation) {
    DWORD error = GetLastError();
    throw std::runtime_error(std::string(operation) + ": " +
                             std::to_string(error));
}

int main(int argc, char** argv) {
    try {
        if (argc != 2) throw std::runtime_error("use: secret seal|open");
        constexpr char test_secret[] = "course-test-not-a-real-secret";
        std::string_view mode(argv[1]);
        if (mode == "seal") {
            DATA_BLOB input{
                static_cast<DWORD>(sizeof(test_secret) - 1),
                reinterpret_cast<BYTE*>(const_cast<char*>(test_secret))
            };
            local_blob protected_data;
            if (!CryptProtectData(&input, L"AmitTech lab", nullptr,
                                  nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN,
                                  &protected_data.value)) fail("protect");
            std::ofstream file("course-secret.bin", std::ios::binary);
            file.write(reinterpret_cast<const char*>(protected_data.value.pbData),
                       protected_data.value.cbData);
            file.close();
            if (!file) throw std::runtime_error("write failed");
            std::cout << "protected bytes=" << protected_data.value.cbData << '\n';
        } else if (mode == "open") {
            std::ifstream file("course-secret.bin", std::ios::binary | std::ios::ate);
            if (!file) throw std::runtime_error("open failed");
            auto length = file.tellg();
            if (length <= 0 || length > 1024 * 1024)
                throw std::runtime_error("invalid lab file size");
            std::vector<BYTE> bytes(static_cast<std::size_t>(length));
            file.seekg(0);
            if (!file.read(reinterpret_cast<char*>(bytes.data()),
                           static_cast<std::streamsize>(bytes.size())))
                throw std::runtime_error("read failed");
            DATA_BLOB input{static_cast<DWORD>(bytes.size()), bytes.data()};
            local_blob plaintext;
            if (!CryptUnprotectData(&input, nullptr, nullptr, nullptr, nullptr,
                                    CRYPTPROTECT_UI_FORBIDDEN, &plaintext.value))
                fail("unprotect");
            bool matches = plaintext.value.cbData == sizeof(test_secret) - 1 &&
                std::memcmp(plaintext.value.pbData, test_secret,
                            sizeof(test_secret) - 1) == 0;
            std::cout << "matches=" << matches << '\n';
            return matches ? 0 : 2;
        } else {
            throw std::runtime_error("unknown mode");
        }
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        return 1;
    }
}

בנו והריצו מתוך תיקיית ניסוי ריקה בטרמינל של כלי הפיתוח.

cl /std:c++20 /EHsc /W4 secret.cpp
.\secret.exe seal
.\secret.exe open

בהרצה תקינה נקבל matches=1. גודל הblob המוגן אינו חייב להיות גודל הסוד. אנחנו לא מפרשים את המבנה הפנימי שלו, לא משנים שדות שנראים כמו header ולא מניחים שגרסה אחרת של המערכת תפיק את אותם bytes.

שימו לב לבעלות: הקלט נשאר שלנו, והפלט הוקצה על ידי הממשק. אנחנו משחררים אותו עם LocalFree, אחרי מחיקת הbuffer באמצעות SecureZeroMemory. הפענוח גם בודק שלמות; בפלט אנחנו מבקשים לוותר על התיאור כדי שלא תהיה הקצאה נוספת לנקות. החוזה מופיע בתיעוד הפונקציה CryptUnprotectData.

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

סוד שמור מול פעולה עם מפתח

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

הממשק Credential Manager מתאים לניהול רשומות credential מזוהות בשם ובסוג. בקריאה לCredWriteW הפעולה קשורה לlogon session של הtoken הנוכחי, וכתיבה לאותו target ואותו type יכולה להחליף רשומה קיימת. לכן שם כמו AmitTech/Lab/EndpointA הוא חלק מהתכנון שלנו, ולא מחרוזת מקרית. לפני שכותבים כלי ניקוי צריך לדעת איזו רשומה הוא מוחק. אפשר לקרוא על חוזה הכתיבה של credential.

המערכת CNG מציעה גם key storage providers. במקרה כזה נוכל להחזיק handle למפתח ולבקש פעולות דרך provider. זה מאפשר להפריד בין השימוש במפתח לבין שמירת חומר המפתח, ויכול להתאים גם לספק חומרה. ההתנהגות והאפשרות לייצא מפתח תלויות בספק ובמדיניות; עצם השימוש בשם CNG אינו מוכיח שהמפתח נמצא בTPM. נבדוק את מודל אחסון המפתחות ואת הספק שבחרנו.

מה קורה כשמחליפים משתמש

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

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

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