前陣子研究某個開源專案的密碼保存做法,想確認加密方法是否安全,學到一招很方便的 .NET 無腦加解密寫法,筆記備忘。

之前介紹過 PowerShell 有個 SecureString,可呼叫 Windows DPAPI (Data Protection API) 用不同機器不同登入身分專屬的金鑰加解密,免除產生及傳遞密碼或金鑰的麻煩,還能做到即使加密內容被偷,不到同一台機器用同一個帳號執行就解不開的保護效果,十分神奇,用來儲存使用者專屬保密資料(例如:記住網站登入密碼)格外方便。而同樣的概念在 .NET 也有對映的 API - ProtectedData Class,背後一樣是靠 DPAPI,提供兩個簡單明瞭的方法:

  • public static byte[] Protect (byte[] userData, byte[]? optionalEntropy, System.Security.Cryptography.DataProtectionScope scope)
  • public static byte[] Unprotect (byte[] encryptedData, byte[]? optionalEntropy, System.Security.Cryptography.DataProtectionScope scope)

Protect() 加密、Unprotect() 解密,DataProtectionScope 指定通用範例,DataProtectionScope.CurrentUser 限定只有當前執行的帳號可以解密、DataProtectionScope.LocalMachine 則是同一台機器的所有程序都可以解密。另外有個 byte[]? optionalEntropy 可傳入額外的密碼熵 (Entrophy,通常是一段隨機亂數資料,提高破解難度),它是選擇性參數,簡單應用時傳 null 即可。

廢話不多說,直接看範例:

using System.Security.Cryptography;
using System.Text;

if (args.Length != 2)
{
    Console.WriteLine("Syntax: ProcDataDemo enc TextToEncrypt");
    Console.WriteLine("        ProcDataDemo dec TextToEncrypt");
}
Console.WriteLine($"目前執行身分: {Environment.UserName}");
var enc = string.Compare(args[0], "enc", true) == 0;
var data = args[1];
try
{
    if (enc)
    {
        var encrypted = ProtectedData.Protect(
            Encoding.UTF8.GetBytes(data),
            null, //optionalEntropy
            DataProtectionScope.CurrentUser
        );
        Console.WriteLine("加密內容:");
        Console.WriteLine(Convert.ToBase64String(encrypted));
    }
    else
    {
        var decrypted = ProtectedData.Unprotect(
            Convert.FromBase64String(data),
            null,
            DataProtectionScope.CurrentUser
        );
        Console.WriteLine("解密結果:");
        Console.WriteLine(Encoding.UTF8.GetString(decrypted));
    }
}
catch (Exception ex)
{
    Console.WriteLine("*** 發生錯誤 ***");
    Console.WriteLine(ex.ToString());
}

ProtectedData 從 .NET Framework 2.0 時代就存在了,到 .NET Core 時代也還有,但需要額外參照 Package 且只支援 Windows 平台。以上的程式碼是用 .NET 6 示範,由於 DPAPI 只有 Windows 平台支援,故 .csproj 要稍加修加,TargetFramework 由 net6.0 改為 net6.0-windows,另外,呼叫 dotnet add package System.Security.Cryptography.ProtectedData 加入參照:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0-windows</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="System.Security.Cryptography.ProtectedData" Version="6.0.0" />
  </ItemGroup>

</Project>

測試加解密 OK!

同一段明文每次 Protect() 產生的加密內容都不相同,但都能用 Unprotect() 解開,比我們用 AES 自己寫加解密演算法更精巧嚴謹。

接著,我用 runas /user:demo cmder 用另一個帳號 demo 測試。加解密同一句明文 "Clear Text" 沒問題,但若試著解密 Jeffrey 帳號的加密結果( AQA...EdM=),會出現 WindowsCryptographyException 錯誤:

接著做另一個實驗,將 DataProtectionScope.CurrentUser 換成 DataProtectionScope.LocalMachine,此時 Jeffrey 跑 Protect() 交給 demo Unprotect() 也能成功解密:

最後,也練習加解密過程加入 Entropy:

using System.Security.Cryptography;
using System.Text;

if (args.Length < 2)
{
    Console.WriteLine("Syntax: ProcDataDemo enc TextToEncrypt");
    Console.WriteLine("        ProcDataDemo dec entropy TextToEncrypt");
}
Console.WriteLine($"目前執行身分: {Environment.UserName}");
var enc = string.Compare(args[0], "enc", true) == 0;
var data = args[enc ? 1 : 2];
try
{
	byte[] entropy;
	
    if (enc)
    {
		//不限長度,可以寫死、動態指定也可隨機產生
		entropy = RandomNumberGenerator.GetBytes(8);
        var encrypted = ProtectedData.Protect(
            Encoding.UTF8.GetBytes(data),
            entropy,
            DataProtectionScope.LocalMachine
        );
        Console.WriteLine("加密內容:");
		Console.WriteLine($"Entropy = {Convert.ToBase64String(entropy)}");
        Console.WriteLine(Convert.ToBase64String(encrypted));
    }
    else
    {
		entropy = Convert.FromBase64String(args[1]);
        var decrypted = ProtectedData.Unprotect(
            Convert.FromBase64String(data),
            entropy,
            DataProtectionScope.LocalMachine
        );
        Console.WriteLine("解密結果:");
        Console.WriteLine(Encoding.UTF8.GetString(decrypted));
    }
}
catch (Exception ex)
{
    Console.WriteLine("*** 發生錯誤 ***");
    Console.WriteLine(ex.ToString());
}

Entropy 可以是任意長度的 byte[],Unprotect() 需與 Protect() 使用相同的值才能正確解密,這段額外內容可以寫死在程式、使用者動態指定或隨機產生 (若求嚴謹應使用 RandomNumberGenerator 這類符合資安要求的隨機數產生器 ),不論是哪種做法,攻擊者就算能在本機用同帳號執行,還需要知道 Entropy 才能解密,再提高一些安全性。

【結論】

與自己決定金鑰呼叫 AES 相比,ProtectedData 提供的加解密函式顯得非常無腦易用,把複雜的金鑰保管機制、加解密運算法通通丟給 Windows 打理,又有一定的安全防護水準,非常適合記憶密碼之類暫存性質資料加密。不過 ProtectedData 對作業系統依賴很深,只能在 Windows 執行不能跨平台也不能跨機器,萬一 Windows 或個人設定檔毁損,加密內容就再也無法解密,應用時一定要留意此一限制。

【延伸閱讀】

Example of how to use ProtectedData to encrypt and decrypt data.


Comments

# by Joker

好用,也可以提供給多系統使用就是要找地方放證書,也可以對證書再多做一層保護,剛好前陣子用到這個,蠻適合拿來對appsettings內容或是其他get方法的參數做加解密

Post a comment