昨天提到 .NET Secret Manager 機制,可取代 appsettings.json 或環境變數作為本機開發測試時的 ApiKey 或密碼保存容器,但美中不足是它用明碼儲存,檔案一旦外流便無險可守。我想應用 Secret Manager 的情境除了開發測試,也會用於在本機跑一些自用的 RAG 或 ChatGPT 整合應用,既然是 Windows 平台,用 DPAPI/ProtectedData 無腦加密感覺是好主意。
(註:DPAPI 的優點是加密不需想金鑰,由 OS 層次鎖定特定主機及登入帳號,即便被改掉密碼強行登入也無法解密)

構想很簡單,我打算設計 ApiKey 或密碼時用 PowerShell SecureString 加密後再存入,設定部分沿用 dotnet user-secrets set CLI;讀取時也沿用 ConfigurationBuilder().AddUserSecrets<Program>()IConfigration["key-name"] 讀取再用 ProtectedData.Unprotect() 解密,就這麼簡單。

最近在看微軟範例時,看到許多場合開始用 C# Notebook 示範測試程式片段。這個概念源自 Python 好用的 Jupyter 筆記本,可在文件穿插 Markdown 說明及 Pythong 程式,很適用來寫作及分享學習筆記;而在 VSCode 裡也有個對等的 Polyglot Notebook,一樣可以穿插程式區塊及 Markdown 區塊,但支援的語言擴及 C#、F#、HTML、JavaScript、KQL、PowerShell、SQL,而在 VSCode 的好處是全程有 Github Copilot 黑魔法加持,用左手就能把程式寫完(誤)。(延伸閱讀:用 Jupyter Notebook 寫 C# / PowerShell / JavaScript 筆記 - Ploygot Notebooks)

我先開了一個 Ploygots .ipynb 文件,在其中寫分別用 PowerShell 讀入字串轉成 SecureString,ConvertFrom-SecureString 轉成 16 進位數字字串解析為 byte[],最後編碼成 Base64 字串寫入檔案 D:\Temp\secret.txt。之後用 C# 引用 System.Security.Cryptography.ProtectedData,讀取 D:\Temp\secret.txt 解碼 Base64 後用 ProtectedData.Unprotect 解密還原得到原文。同一個 .ipynb 中同時使用 PowerShell 及 C#,還自帶文字說明,感覺很酷。

實驗成功後,下一步我用一小段程式以 PowerShell 讀取使用者輸入內容轉 SecureString,將 16 進位字串用 dotnet.exe user-secrets set "ApiKey" "dpapi:$encApiKey" --project ".\user-secret.csproj" 在 Secret Manager 存入 ApiKey 設定 (前方加上 enc: 前綴,方便 C# 端判斷是否需解密),我還另外設定一筆未加密的ApiUrl設定作為對照;之後用 PowerShell 從 .csproj XML 得到 UserSecretsId 算出 secrets.json 完整路徑,讀取內容觀察寫入值:

$encApiKey = Read-Host "ApiKey" -AsSecureString | ConvertFrom-SecureString
dotnet.exe user-secrets set "ApiKey" "enc:$encApiKey" --project ".\user-secret.csproj"
dotnet.exe user-secrets set "ApiUrl" "https://api.example.com" --project ".\user-secret.csproj"

設定完成後,來看 C# Program.cs 端如何讀取。還蠻簡單的,new ConfigurationBuilder().AddUserSecrets<Program>().Build() 引用 Secret Manager 建立 IConfiguration,用 config["ApiKey"] 讀到 "enc:01000000d08c9ddf0115d111..." 後,偵測到 "enc:" 字首時用將 16 進位字串還原 byte[],用 ProtectedData.Unprotect() 解密;若無 "enc:" 前綴則為明碼,直接取值使用。

using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Configuration;

IConfiguration config = new ConfigurationBuilder()
        .AddUserSecrets<Program>().Build();
Console.WriteLine("API Key: " + ReadValueFromSecretManager("ApiKey"));
Console.WriteLine("API Url: " + ReadValueFromSecretManager("ApiUrl"));

string ReadValueFromSecretManager(string key) {
    var val = config[key];
    if (string.IsNullOrEmpty(val) || !val.StartsWith("enc:")) return val!;
    var hex = val.Substring(4);
    var bytes = new byte[hex.Length / 2];
    for (int i = 0; i < bytes.Length; i++) {
        bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
    }
    return Encoding.Unicode.GetString(ProtectedData.Unprotect(bytes, null, DataProtectionScope.CurrentUser));
}

測試結果,加密的 ApiKey 與未加密碼的 ApiUrl 都成功讀取,輕鬆搞定:

就醬,透過幾行程式為 Secret Manager 加上加密功能,用起來就更安心囉~


Comments

Be the first to post a comment

Post a comment