Windows 應用程式不要把機密資訊以明文存進設定檔的最佳實踐

· · Windows 開發, 資安, DPAPI, C# / .NET, Win32

前一篇 「Windows 應用程式開發中為了守住最底限資安的檢查表」 裡,
寫下了「不要把機密資訊放進原始碼或明文設定」「Win32 / .NET 的話就用 DPAPI / ProtectedData」這條最底限。

這次把其中 「用 DPAPI 讓它至少比明文好一點」 這一塊再挖深一點。

適用對象是下列這類 Windows 應用程式。

  • WPF / WinForms / WinUI 的桌面應用程式
  • C# / .NET 的 Windows 客戶端
  • 會忍不住把連線端憑證或 API Token 存進本機設定檔的應用程式

這篇要談的是,「對於不得不存在本機的機密,至少不要放任它在 appsettings.json 裡以明文放著」 的現實設計。
這不是「任何攻擊者都打不穿的完全防禦」的話題。那種說法一旦過頭,資訊安全會瞬間變成怪談。

1. 先說結論

先把結論寫出來,實務上依下列順序思考會比較清楚。

  1. 根本上不要讓客戶端持有長期機密
    • 優先採用 Windows 驗證、整合驗證、使用者互動式登入、伺服端機密管理
  2. 如果真的必須存在本機,就不要以明文存放
    • Windows 上首先考慮 DPAPI / ProtectedData
  3. 一般桌面應用程式基本用 DataProtectionScope.CurrentUser
    • LocalMachine 的適用範圍相當窄
  4. DPAPI 不是用來擋「端末已被完全淪陷」這種狀況的
    • 以相同使用者權限執行的程式碼,原則上能解密該使用者能解密的東西

而這篇文章最重要的論點是下面這個。

「反正金鑰總得存在某處,那不管是明文還是 DPAPI,就資安來說不都一樣嗎?」

這句話一半對,但結論錯了。

  • 自己做 AES + 金鑰放在同一個應用程式或同一個設定檔 的話,其實相當接近明文
  • DPAPI 是把金鑰管理交給 OS,並把能解密的主體綁定到「那個 Windows 使用者」或「那台電腦」
  • 結果,對於 設定檔單獨外流、被帶到別台 PC、誤寄、備份外流、不小心混進儲存庫 這類事故的防禦力會有很大的差異

換言之,
只看「金鑰放在某處」這個抽象說法的話是一樣,但
「誰,在什麼情境下,能多簡單地用得到」這件事完全不同

把「把鑰匙放在玄關踏墊下」和「到管理室本人認證後才拿鑰匙」當成同一件事,有點粗暴。

2. 為什麼明文設定危險

明文保存危險的理由,比起密碼學,更多是很「土」的現實。實務上大致是以下幾種管道漏出去。

  • 設定檔就這樣被放進 Git
  • 故障調查用的 ZIP 裡整個設定檔被打包進去
  • 回報問題時被要求附上設定檔
  • 備份或檔案分享讓第三者讀到
  • 日誌裡直接印出連線字串或 Token
  • 離職員工或其他使用者能讀到同一台電腦上的檔案

明文最大的問題是 「被讀到的那一瞬間,機密就已經結束」

  • 檔案被打開就結束
  • 被複製就結束
  • 被附在信件就結束
  • 一旦留在儲存庫,就要半永久地背著它

攻擊者根本不需要很高深的技術。
「用文字編輯器就打得開」這件事本身就已經相當弱。

3. 對「反正金鑰總會存在某處,那不是一樣嗎?」的回答

這個疑問很合理。
而且如果這裡回答得太草率,整篇資安文章會立刻變得朦朧。

先講結論:以「某處一定要有金鑰」這層意思來說 yes,但要說「所以都一樣」就是 no。

3.1. 哪裡一樣、哪裡不一樣

的確,加密最後總得有某個 root of trust。
機密不會從宇宙某處免費冒出來。這一塊沒有寬待。

但資安上的差異,是由下列 3 點決定的。

  • 金鑰是不是應用程式自己在持有
  • 金鑰綁在哪個主體身上
  • 只有檔案被拿走時是否能解開

把這些差異粗略整理成表,大致如下。

方式 設定檔被讀到 只有檔案被帶到別台 PC 被同一台 PC 的其他使用者讀到 以相同使用者權限執行的程式碼
明文 當場外流 直接外流 直接外流 當然讀得到
自做加密+金鑰放在同一設定/執行檔 相當會漏 相當會漏 相當會漏 當然解得開
DPAPI + CurrentUser 檔案單獨無法立刻讀 通常難以解密 通常難以解密 可以解密
DPAPI + LocalMachine 檔案單獨無法立刻讀 在別台 PC 通常難以解密 同一台 PC 上廣泛可解密 可以解密

這裡重要的是,DPAPI 把「能讀檔案」和「能使用機密」分開

在明文情境下,這兩件事是一樣的。
檔案讀得到,機密也就讀得到。

但在 DPAPI 下,至少 CurrentUser 時,必須滿足:

  • 以該 Windows 使用者身分
  • 在該 Windows 的上下文中
  • 透過 OS 的保護機制

才能解密。

這個差距在事故現場會相當大。

3.2. 「但同一個使用者的話還是解得開吧?」——沒錯

這一點要老老實實地寫出來。

以相同使用者權限執行的程式碼,原則上能解開該使用者可解的內容。

也就是說,DPAPI 並不是為下列情境設計的。

  • 端末已經被惡意軟體攻陷
  • 攻擊者能以那個使用者的身分執行程式碼
  • 端末管理者層級已完全被接管

這種狀態下,既然應用程式本身也能解密,攻擊者程式碼也就解得開。
在這個場景下說「可是我有加密」其實不太能讓人安心。

DPAPI 比較能發揮的是「檔案外流、放錯位置、離線帶走、被其他使用者看到」這些側面。

這邊如果搞錯,會同時發生:

  • 把能守的東西低估而不用
  • 把守不住的東西高估而安心

兩種狀況。兩邊都默默地很危險。

3.3. 所以到底好在哪

DPAPI 的好處一句話講完就是:

「能把機密本身,從設定檔的可讀性裡切開。」

舉例來說,下列事故在明文和 DPAPI 之間就會出現差異。

  • 使用者把設定檔寄給客服
  • 調查用 ZIP 裡塞了設定檔
  • 備份中只有設定檔外流
  • 被複製到共享資料夾
  • 開發者只看得到密文而看不到內容

這些是相當現實的好處。
不需要把攻擊者想成電影裡的超人,也能把日常事故的半徑縮小。

4. DPAPI 剛剛好的理由

在 Windows 上處理本機保存的機密時,DPAPI 在實務上剛剛好的理由如下。

4.1. 可以把金鑰管理推給 OS

自己生成 AES 金鑰、保存、給權限、輪替、考慮外洩影響,還要加上竄改偵測。
比想像中沉重。而且做得不認真的話,大概就是把金鑰放在同一個地方結束。

用 DPAPI 就能 把「加密金鑰怎麼做、放哪裡」這個問題,從應用程式實作中切開。

從這個意義上,與其把 DPAPI 看成
「挑選加密演算法的 API」,更接近它本質的說法是「把金鑰管理委託給 OS 的 API」

4.2. 能把解密主體綁在 Windows 使用者或電腦上

一般桌面應用程式,絕大多數情境都可以選 CurrentUser

  • 以該使用者已登入
  • 以該使用者上下文在執行

為前提解密。

這使得 只把密文複製到別台 PC 也很難直接使用 的性質變成可得。

4.3. 較容易順便附帶竄改偵測

自做加密常見的是,
「反正我 AES 加密了就結束」然後 忘了加上竄改偵測

DPAPI 對加密資料本身就具備完整性保護,所以
暗中偷改密文的偵測 也順便交給 OS 的機制,實務上是一個好處。

4.4. 從 C# / .NET 自然就能使用

C# 直接用 System.Security.Cryptography.ProtectedData 就可以。
不用再加掛多餘的程式庫,對 Windows 專用程式來說幫助不小。

5. DPAPI 能守、不能守的東西

這裡最好明確分開,比較安全。

5.1. 變得比較能守的

DPAPI 至少在下列場面是有效的。

  • 設定檔明文外洩
  • 把檔案帶到別台 PC
  • 同一台 PC 上其他使用者嘗試讀取(CurrentUser 前提)
  • 以備份或附件形式外流
  • 開發/維運現場「不小心就被讀到」的狀態

5.2. 守不住或很弱的

另一方面,在下列情境就不要太相信它。

  • 以相同使用者權限執行的攻擊程式碼
  • 端末本身完全淪陷
  • 被管理員權限接管
  • 應用程式解密後,記憶體裡的明文
  • 被灑給所有客戶端、共用的長期機密

最後那個「所有客戶端共用的長期機密」特別重要。

例如:

  • 所有客戶嵌入同一把 API 金鑰
  • 所有端末用同一把共用密碼
  • 只靠客戶端就能完成的固定解密金鑰

這類設計 只要有一台被破,就很容易波及整體

DPAPI 對於「把保存地點做得比明文好」是有效的,
它無法替「本來就不該放在客戶端的機密」背書。

6. CurrentUserLocalMachine 的使用區分

這裡相當重要。隨便選會讓意義完全不同。

6.1. 基本是 CurrentUser

一般 Windows 桌面應用程式,首先以 CurrentUser 為基本。

適合的例子:

  • WPF / WinForms / WinUI 的使用者向桌面應用程式
  • 每位使用者各自持有設定或憑證的應用程式
  • 把設定放在 %LocalAppData%%AppData% 底下的應用程式

這種情況下,會變成 「那個 Windows 使用者的機密」,處理起來比較自然。

6.2. LocalMachine 的用途相當有限

LocalMachine 看起來很方便,但對一般桌面應用程式來說範圍太廣。

適合的場景,例如:

  • 信任的單一用途機器上的 Windows 服務
  • 只會在那台機器的特定 Process 使用的機密
  • 必須跨登入使用者在同一台機器上共用的情境

不過注意事項相當沉重。

  • 那台 PC 上執行的各種 Process 都能廣泛解密
  • 共用端末、RDS、跳板、多使用者共用環境容易變得危險
  • 以「反正大家都能用很方便」為由選它,通常後面都會後悔

6.3. 猶豫時這樣想

  • 一般 UI 應用程式 -> CurrentUser
  • 真的需要以機器為單位守住的特殊情況 -> LocalMachine
  • 任何使用者都要能解密、但端末上還有其他使用者在 -> 通常最好從設計重看一次

6.4. 服務或 impersonation 時注意事項會變多

把 Windows 服務或 impersonation 攪在一起時,CurrentUser 的意思會變重。

  • 執行帳號是誰
  • 該帳號的 Profile 是不是已被載入
  • 解密時機是在哪個上下文

只要這些對不上,就很容易 「加密得起來但解不開」
服務用途通常不能「反正先 CurrentUser」就解決。

7. 實作的最底限方針

在 Windows 應用程式中,光是「不要再用明文設定」,設計其實不用搞得太複雜。
不過有幾個不想漏掉的要點。

7.1. 只保護機密本身

與其整個設定全都加密,不如先只保護 機密欄位,比較好處理。

例如,像下面這樣分開。

  • Server URL
  • 使用者名稱
  • DB 名稱
  • 功能旗標

這些通常可以維持明文。

另一方面,

  • 密碼
  • API Token
  • Refresh Token
  • 共享資料夾憑證

是保護對象。

這樣分之後,

  • 編輯設定比較容易
  • 比對差異比較容易
  • 清楚知道哪裡是機密
  • 整體運維比較簡單

會同時成立。

7.2. 儲存位置以 per-user 為基本

一般桌面應用程式,儲存位置基本選在下列這類 per-user 位置。

  • %LocalAppData%\Vendor\App\settings.json
  • %AppData%\Vendor\App\settings.json

至少 不要隨便放在安裝目錄底下或容易被共享的地方

就算用 DPAPI 保護,儲存位置 ACL 亂七八糟的話,
就會變成「密文被讀到」「設定結構被看光」「運維失誤照樣發生」的故事。防禦不要只有一層,要疊起來才有效。

7.3. optionalEntropy 不是萬能的第二把金鑰

ProtectedData 可以傳入 optionalEntropy
很方便,但 「把它嵌進執行檔就會變安全的第二把魔法金鑰」這種東西是不存在的。

  • 放在同一個檔案裡,就不會是秘密
  • 用固定值塞進執行檔,也稱不上是強機密
  • 不過對用途識別或誤用防止仍有幫助

實務上用法,比如:

  • 應用程式名稱
  • 用途名稱
  • 版本識別字

以固定的 byte 陣列傳入,
為了「不要不小心接受到其他用途的密文」 使用,拿捏這種程度就剛好。

7.4. 不是說「密文就可以丟進 Git」

這點低調但重要。

DPAPI 的密文比明文好得多,但
並不代表設定檔可以整個放進儲存庫。

理由很直白:

  • 密文會留很久
  • 總有一天同一台端末、同一個上下文可能會被重現
  • 檔案裡還有機密以外的資訊
  • 容易養出「反正有保護,可以隨便對待」的文化

「比明文好」
「放哪裡都安全」
是完全不同的兩件事。

7.5. 不要寫進日誌

意外常見的陷阱,是解密之後把東西寫進日誌,前面的努力就全白費。

  • 連線失敗時把整個連線字串印出來
  • API 401 時保留 Authorization header
  • 把機密混進例外訊息

只要做出上面那些事,就算不再用明文設定檔,日誌最後還是會變成明文倉庫。
雖然很悲哀,但相當寫實。

8. C# / .NET 的最小實作範例

下面是一個用 CurrentUser 保護準備存進設定檔的字串的最小例子。
為了用途識別放了固定的 optionalEntropy,但 千萬不要把它當成秘密金鑰

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

public static class DpapiSecretProtector
{
    // 用途識別用。不是第二把秘密金鑰。
    private static readonly byte[] Entropy =
        Encoding.UTF8.GetBytes("ComComponent:DesktopApp:SettingsSecret:v1");

    public static string ProtectToBase64(string plaintext)
    {
        ArgumentNullException.ThrowIfNull(plaintext);

        byte[] plainBytes = Encoding.UTF8.GetBytes(plaintext);
        byte[] protectedBytes = Array.Empty<byte>();

        try
        {
            protectedBytes = ProtectedData.Protect(
                plainBytes,
                optionalEntropy: Entropy,
                scope: DataProtectionScope.CurrentUser);

            return Convert.ToBase64String(protectedBytes);
        }
        finally
        {
            Array.Clear(plainBytes, 0, plainBytes.Length);

            if (protectedBytes.Length > 0)
            {
                Array.Clear(protectedBytes, 0, protectedBytes.Length);
            }
        }
    }

    public static string UnprotectFromBase64(string protectedBase64)
    {
        ArgumentNullException.ThrowIfNull(protectedBase64);

        byte[] protectedBytes = Convert.FromBase64String(protectedBase64);
        byte[] plainBytes = Array.Empty<byte>();

        try
        {
            plainBytes = ProtectedData.Unprotect(
                protectedBytes,
                optionalEntropy: Entropy,
                scope: DataProtectionScope.CurrentUser);

            return Encoding.UTF8.GetString(plainBytes);
        }
        finally
        {
            Array.Clear(protectedBytes, 0, protectedBytes.Length);

            if (plainBytes.Length > 0)
            {
                Array.Clear(plainBytes, 0, plainBytes.Length);
            }
        }
    }
}

使用方式很單純。

string protectedPassword = DpapiSecretProtector.ProtectToBase64(password);

// 儲存到 JSON 等
// settings.DbPasswordProtected = protectedPassword;

string password = DpapiSecretProtector.UnprotectFromBase64(settings.DbPasswordProtected);

設定檔可以做成例如下面這樣。

{
  "ApiBaseUrl": "https://api.example.com/",
  "UserName": "app-user",
  "PasswordProtected": "AQAAANCMnd8BFdERjHoAwE..."
}

這種形狀的好處在於:

  • URL 和使用者名稱可以照常編輯
  • 只有密碼被保護
  • 設定結構很好讀
  • 比明文擺在那邊不容易出事

9. 即使如此仍然危險的設計

即便用了 DPAPI,下列設計仍然危險。

9.1. 把解密後的值到處長時間帶著走

把解密後的值:

  • 寫進日誌
  • 顯示在畫面上
  • 塞進例外
  • 長時間掛在長壽命物件上

這些都是想避免的行為。

「存的時候加密」
「使用期間也安全」
是兩件不同的事。

9.2. 讓所有安裝共用同一份機密

例如讓所有使用者共用同一把 API 金鑰,這種設計就算用 DPAPI 存也不能根本解決。

因為 只要任何一台上應用程式能解密,那份機密就能被抽出來

這類機密應該:

  • 放到伺服端
  • 客戶端只持有 Token
  • 改成以使用者為單位的憑證
  • 採用帶有期限的 Token

往這些方向挪動比較好。

9.3. 因為「比較輕鬆」就選 LocalMachine

這種情況真的很常見。

  • 使用者切換還是讀得到
  • 服務也能讀
  • 能動就方便

這些理由都會讓人很想選 LocalMachine

但對一般桌面應用程式來說,這個選擇會
「把解密可能性擴大到那台 PC 上的其他行程」,
意義相當不同。

9.4. 再疊一層自家加密就以為安心

用自己的加密取代 DPAPI,例如:

  • 把 AES 金鑰寫死在原始碼
  • 把 AES 金鑰寫在設定檔的另一個欄位
  • 把「稍微混淆過的字串」當金鑰用

這些做法大多效果有限。

「不是明文」和「安全」之間還有一條相當大的溝。

10. DPAPI 不夠用的情境

DPAPI 方便,但不是萬能。下列情境應該考慮其他方案。

10.1. 想在 Windows 以外也跑

DPAPI / ProtectedData 是 Windows 專用的。
跨平台應用程式無法以此為前提建構。

10.2. 想要在多台機器、多位使用者間處理同一份機密

想讓同一份密文在多台 PC 都能解開、想讓多位使用者共用,
都跳出了「把機密綁在那台端末、那位使用者身上」這個 DPAPI 的拿手領域。

這種情況應該考慮:

  • 伺服端的機密管理
  • 憑證管理基礎架構
  • Windows 驗證 / 整合驗證
  • 應用程式用的憑證庫

依需求採用其他設計。

10.3. 要保存的就是使用者的帳號密碼本身

如果是 packaged desktop app / WinUI 系,而且保存對象明確是:

  • 使用者名稱
  • 密碼

這組,那麼 Credential Locker 也是可選之一。
不過本文的主軸仍然是 「Windows 客戶端不要再用明文設定檔」 的 DPAPI 實務線。

11. 實務上建議的優先順序

最後,如果在實務中猶豫,依下列順序思考會比較好整理。

優先 1:一開始就不要持有

  • Windows 驗證
  • 整合驗證
  • 互動式登入
  • 在伺服端保管機密
  • 短命 Token

優先 2:向「每位使用者的機密」靠攏

  • 捨棄共用機密、改走 per-user
  • 捨棄長期固定憑證、改用可更新的 Token
  • 避免所有客戶端共用同一把金鑰

優先 3:如果必須本機保存,就用 DPAPI

  • 通常是 CurrentUser
  • 儲存位置以 per-user
  • 只保護機密欄位
  • 不要寫進日誌

優先 4:LocalMachine 當例外處理

  • 是否真的必須以機器為單位
  • 那台端末會不會有其他使用者登入
  • 就服務設計而言是否合理

12. 結語

在 Windows 應用程式中必須把機密資訊存進設定檔時,
應盡量避免以明文放著。

然後對於:

「反正金鑰總得存在某處,那不都一樣嗎?」

這個疑問,比較實務的回答是:

  • 自己做加密、金鑰放在同一個地方的話,幾乎一樣
  • DPAPI 並不一樣
    • 可以把金鑰管理推給 OS
    • 可以把解密主體綁在 Windows 使用者/電腦上
    • 能避免讓檔案單獨外流直接等於機密外流
  • 但是
    • 以相同使用者權限執行的程式碼
    • 完全被攻陷的端末
    • 根本不該放在客戶端的長期共用機密

這幾件事,它解決不了。

換言之,DPAPI 不是 萬能的城牆
但它至少能 把「設定檔明文」這面整個透光的玻璃,換成一面起碼像樣的窗戶

在 Windows 客戶端的實務中,這個差距相當大。
先從這裡不要搞錯開始,才是最現實的切入點。

13. 參考資料

  • 前一篇文章: https://comcomponent.com/blog/2026/03/14/001-windows-app-security-minimum-checklist/
  • Microsoft Learn: CryptProtectData
    https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata
  • Microsoft Learn: ProtectedData
    https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.protecteddata?view=windowsdesktop-10.0
  • Microsoft Learn: DataProtectionScope
    https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.dataprotectionscope?view=windowsdesktop-10.0
  • Microsoft Learn: How to: Use Data Protection
    https://learn.microsoft.com/en-us/dotnet/standard/security/how-to-use-data-protection
  • Microsoft Learn: Credential locker for Windows apps
    https://learn.microsoft.com/en-us/windows/apps/develop/security/credential-locker

相關文章

共用相同標籤的最新文章。能以相近的主題延伸理解。

相關主題

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

與本主題相關的服務

本文連結到以下服務頁面,歡迎從最接近的入口查看。

回到部落格一覽