Skip to content

הצפנת מחרוזות באמצעות ביטוי סיסמה ו־AES-GCM מאומת ב־.NET

איור בנושא הצפנה

כל הקוד נועד להמחשה ולא הורץ.

המאמר המקורי קישר לדוגמת הצפנה באמצעות ביטוי סיסמה והעתיק את הקוד שלה. המקור ההיסטורי ב־Stack Overflow נשמר לצורך ייחוס, לא כמימוש מומלץ. הקוד הזה משתמש ב־CBC ללא אימות ובגודל בלוק של 256 סיביות ב־Rijndael. גודל הבלוק של AES הוא 128 סיביות; בחירת מפתח בן 256 סיביות אינה משנה זאת. גם גזירת המפתח הישנה עם 1,000 איטרציות וקריאה יחידה מ־CryptoStream אינן בסיס מתאים לפורמט חדש.

עבור נתונים חדשים, החלופה להמחשה משתמשת ב־AES-256-GCM, שמאמת את הטקסט המוצפן לפני שהקורא מקבל טקסט גלוי. PBKDF2-HMAC-SHA-256 גוזר מפתח בן 32 בתים מביטוי סיסמה וממלח אקראי חדש בן 16 בתים. מקדם העבודה הקבוע הוא 600,000 איטרציות. הוא נשען על נקודת ייחוס עדכנית של OWASP ל־PBKDF2; יש למדוד ולבחון אותו לעומס העבודה שלכם. זו אינה הבטחת ביצועים כללית או טענה להסמכת FIPS. עדיין נדרש ביטוי סיסמה חזק, משום שתוקף המחזיק בטקסט המוצפן יכול לנסות ניחושים לא מקוונים.

מעטפת מאומתת בעלת גרסה

מבנה הפורמט הוא version (1) | iterations (4, big-endian) | salt (16) | nonce (12) | tag (16) | ciphertext, והגדלים נמדדים בבתים. הכותרת ומחרוזת הקשר קבועה של היישום מאומתות כנתונים נלווים. הפענוח דוחה גרסאות או מספרי איטרציות אחרים לפני גזירת המפתח, כך שקלט לא מהימן אינו יכול לדרוש מקדם עבודה שרירותי. הדוגמה מוגבלת לטקסט גלוי של 1 MiB בקידוד UTF-8 ולביטויי סיסמה עד 1,024 יחידות קוד של UTF-16.

זהו קוד C# להמחשה המיועד ל־API של AES-GCM ב־.NET 8 ומעלה, עם גודל תג מפורש. הוא נבדק מול תיעוד ה־API, אך לא הודר, הורץ או נבדק בבדיקות בפועל כאן. הוא אינו תחליף לרכיב קריפטוגרפי של יישום שעבר ביקורת.

using System;
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;

public static class PassphraseCipher
{
    private const byte Version = 1;
    private const int Iterations = 600_000;
    private const int SaltSize = 16;
    private const int NonceSize = 12;
    private const int TagSize = 16;
    private const int HeaderSize = 1 + 4 + SaltSize + NonceSize;
    private const int MaxPlaintextBytes = 1_048_576;
    private const int MaxEncodedChars = ((HeaderSize + TagSize + MaxPlaintextBytes + 2) / 3) * 4;
    private static readonly UTF8Encoding Utf8 = new(false, true);
    private static readonly byte[] Context = Encoding.ASCII.GetBytes("SysSeder.StringCipher.v1");

    private static void CheckPassphrase(string passphrase)
    {
        if (string.IsNullOrEmpty(passphrase) || passphrase.Length > 1024)
            throw new ArgumentException("Passphrase is empty or too long.");
        if (!AesGcm.IsSupported)
            throw new PlatformNotSupportedException("AES-GCM is unavailable.");
    }

    private static byte[] AssociatedData(ReadOnlySpan<byte> header)
    {
        var result = new byte[Context.Length + HeaderSize];
        Context.CopyTo(result, 0);
        header.CopyTo(result.AsSpan(Context.Length));
        return result;
    }

    public static string Encrypt(string plaintext, string passphrase)
    {
        CheckPassphrase(passphrase);
        ArgumentNullException.ThrowIfNull(plaintext);
        if (plaintext.Length > MaxPlaintextBytes || Utf8.GetByteCount(plaintext) > MaxPlaintextBytes)
            throw new ArgumentException("Plaintext exceeds the example's 1 MiB limit.");
        byte[] plain = Utf8.GetBytes(plaintext);
        byte[] envelope = new byte[HeaderSize + TagSize + plain.Length];
        Span<byte> header = envelope.AsSpan(0, HeaderSize);
        header[0] = Version;
        BinaryPrimitives.WriteInt32BigEndian(header.Slice(1, 4), Iterations);
        Span<byte> salt = header.Slice(5, SaltSize);
        Span<byte> nonce = header.Slice(5 + SaltSize, NonceSize);
        RandomNumberGenerator.Fill(salt);
        RandomNumberGenerator.Fill(nonce);
        byte[] key = Rfc2898DeriveBytes.Pbkdf2(passphrase, salt.ToArray(),
            Iterations, HashAlgorithmName.SHA256, 32);
        try
        {
            using var aes = new AesGcm(key, TagSize);
            aes.Encrypt(nonce, plain,
                envelope.AsSpan(HeaderSize + TagSize),
                envelope.AsSpan(HeaderSize, TagSize), AssociatedData(header));
            return Convert.ToBase64String(envelope);
        }
        finally
        {
            CryptographicOperations.ZeroMemory(key);
            CryptographicOperations.ZeroMemory(plain);
        }
    }

    public static string Decrypt(string encoded, string passphrase)
    {
        CheckPassphrase(passphrase);
        if (string.IsNullOrEmpty(encoded) || encoded.Length > MaxEncodedChars)
            throw new CryptographicException("Invalid encrypted value.");
        byte[] envelope;
        try { envelope = Convert.FromBase64String(encoded); }
        catch (FormatException) { throw new CryptographicException("Invalid encrypted value."); }
        if (envelope.Length < HeaderSize + TagSize
            || envelope.Length > HeaderSize + TagSize + MaxPlaintextBytes)
            throw new CryptographicException("Invalid encrypted value.");
        ReadOnlySpan<byte> header = envelope.AsSpan(0, HeaderSize);
        if (header[0] != Version
            || BinaryPrimitives.ReadInt32BigEndian(header.Slice(1, 4)) != Iterations)
            throw new CryptographicException("Unsupported encrypted-value format.");
        ReadOnlySpan<byte> salt = header.Slice(5, SaltSize);
        ReadOnlySpan<byte> nonce = header.Slice(5 + SaltSize, NonceSize);
        byte[] key = Rfc2898DeriveBytes.Pbkdf2(passphrase, salt.ToArray(),
            Iterations, HashAlgorithmName.SHA256, 32);
        byte[] plain = new byte[envelope.Length - HeaderSize - TagSize];
        try
        {
            using var aes = new AesGcm(key, TagSize);
            aes.Decrypt(nonce,
                envelope.AsSpan(HeaderSize + TagSize),
                envelope.AsSpan(HeaderSize, TagSize), plain, AssociatedData(header));
            return Utf8.GetString(plain);
        }
        catch (CryptographicException)
        {
            throw new CryptographicException("Unable to decrypt encrypted value.");
        }
        finally
        {
            CryptographicOperations.ZeroMemory(key);
            CryptographicOperations.ZeroMemory(plain);
        }
    }
}

טיפול ב־nonce, מגבלות והעברת נתונים

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

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

הפורמט הזה אינו תואם במכוון לפלט Rijndael-CBC הישן. אין לעבור בשקט ל־CBC ישן כשהאימות של GCM נכשל. העברה נפרדת ומבוקרת חייבת לזהות רשומות ישנות מהימנות, לפענח אותן בפורמט הישן רק במסגרת אותה העברה ולהצפינן מחדש בפורמט החדש. אין לחשוף את המפענח הישן כשירות רשת המאפשר ללמוד מתוצאות הפענוח. בדיקות לפני פריסה צריכות לכלול הצפנה ופענוח חוזרים, קלט ריק ו־Unicode, ביטויי סיסמה שגויים, קיטוע, שינויים בכותרת, בתג ובטקסט המוצפן, ומגבלות משאבים. אלה המלצות לבדיקה, לא תוצאות שדווחו.

מקורות ראשוניים

המאמר המקורי באנגלית: String encryption with passphrase and AES in CBC mode. תאריך הפרסום המקורי: 2022-05-29T08:04:00.006+03:00.

Post a Comment

Previous Post Next Post