更新紀錄(2 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
引用本文(DOI: 10.5281/zenodo.21616287)
本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。
Go Komura(2026)。〈Windows 應用程式的機密資訊保存 - 用 DPAPI 避免明文設定〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616287 https://comcomponent.com/zh-TW/blog/2026/03/16/000-windows-app-secret-storage-best-practices-dpapi/
- DOI(最新版本)
- 10.5281/zenodo.21616287
- DOI(此版本)
- 10.5281/zenodo.22297126
在前一篇「Windows 應用程式開發要守住的最低限度資安檢核表」裡,
寫下了「不要把機密資訊放進原始碼或明文設定」「Win32 / .NET 就用 DPAPI / ProtectedData」這條最低限度的界線。
這次要把其中的「用 DPAPI 讓它至少比明文好一些」再往下挖一層。
適用對象是下列這類 Windows 應用程式。
- WPF / WinForms / WinUI 的桌面應用程式
- C# / .NET 的 Windows 用戶端
- 會想把連線對象的認證資訊或 API 權杖存進本機設定檔的應用程式
這裡要談的,是「對於不得不存在本機的機密,至少不要讓它以明文留在 appsettings.json 裡」的現實設計。
這不是「能擋住任何攻擊者的完全防禦」。話講得太滿,就會離現實的資安討論越來越遠。
1. 先說結論
在實務上,照這個順序思考會比較清楚。
- 根本上就不要讓用戶端持有長期機密
- 優先採用 Windows 驗證、整合式驗證、使用者的互動式登入,以及伺服器端的機密管理
- 如果真的必須存在本機,就不要以明文放著
- 在 Windows 上,先把 DPAPI /
ProtectedData當成第一候選
- 在 Windows 上,先把 DPAPI /
- 一般的桌面應用程式以
DataProtectionScope.CurrentUser為基本LocalMachine的適用範圍相當有限
- DPAPI 並不是用來擋住「電腦已被完全入侵」這種狀況的
- 以相同使用者權限執行的程式碼,原則上解得開那個使用者解得開的東西
而本文最重要的論點是下面這個。
「反正私密金鑰總得存在某個地方,那麼明文和 DPAPI 在資安上不是一樣嗎?」
這話對了一半,但結論是錯的。
- 如果是自製 AES 加密,又把金鑰放進同一個應用程式或同一份設定,那確實相當接近明文
- 但 DPAPI 把金鑰管理集中到 OS,並把能解密的主體綁定到「那個 Windows 使用者」或「那台電腦」
- 結果是,面對設定檔單獨外流、被帶到別台 PC、誤寄、備份外流、混進儲存庫這類事故時,防禦力差很多
也就是說, 只看「金鑰總在某處」這種抽象論會覺得一樣,但 「誰、在什麼脈絡下、能多輕易地使用它」完全不同,就是這麼回事。
把鑰匙藏在門口踏墊底下,和到管理室核對身分之後才領鑰匙,斷言這兩者相同未免太粗糙。
flowchart TB
accTitle: 光說「金鑰總在某處」並不會讓兩者相同
accDescr: 說明只看金鑰總在某處這種抽象論時明文與 DPAPI 看起來一樣,但改看誰在什麼脈絡下能多輕易地使用它就完全不同,這個貫穿全文核心論點的圖。
abs1["金鑰總在某處(抽象論)"] -.->|"只看這裡"| same1["看起來一樣"]
who1["誰、在什麼脈絡下、能多輕易地使用"] -->|"看這裡"| diff1["完全不同"]
圖 1: 抽象論上看起來一樣,但改看「誰在什麼脈絡下能使用」,明文與 DPAPI 就分開了。
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 21 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
2. 為什麼明文設定很危險
明文保存之所以危險,理由比密碼學理論要瑣碎現實得多。在實務上,大致都是循這幾條路徑外洩的。
- 把設定檔原封不動提交進 Git
- 故障調查用的 ZIP 裡整份包進了設定檔
- 請對方在支援詢問時附上設定檔
- 備份或檔案共用讓第三方讀得到
- 日誌裡直接輸出連線字串或權杖
- 離職者或其他使用者讀得到同一台電腦上的檔案
明文只要被第三方讀到,機密性就沒了。
- 檔案打得開就結束了
- 能複製就結束了
- 被夾進郵件附件就結束了
- 留在儲存庫裡,就得幾乎沒完沒了地收拾
攻擊者甚至不需要多高明。 「用文字編輯器就打得開」,光這一點就已經相當脆弱。
flowchart TB
accTitle: 明文被讀到的當下就失去機密性
accDescr: 說明設定檔透過混進 Git、調查用 ZIP、備份外流、郵件附件這類瑣碎路徑被第三方讀到之後,明文就會失去機密性的圖。
rt1["混進 Git、調查用 ZIP、郵件附件、備份"] --> read1["設定檔被讀到"]
read1 --> end1["被第三方讀到的當下就失去機密性"]
end1 -.-> low1["攻擊者甚至不需要多高明"]
圖 2: 明文的弱點在於,只要循日常的事故路徑被第三方讀到,機密性就沒了。
3. 對「私密金鑰總得存在某處,不是一樣嗎?」的回答
這個疑問很合理。 而且,這裡如果隨手帶過,資安文章馬上就會變得空泛。
要回答的話:就「總得有個地方放金鑰」這層意思而言是 yes,但就「所以兩者相同」這層意思而言是 no。
3.1. 什麼相同,什麼不同
的確,加密最終需要某種 root of trust。 也就是說,加密最終需要某個信任的起點。
不過,資安上的差別由下列 3 點決定。
- 金鑰是不是由應用程式自己直接持有
- 金鑰綁定在哪個主體上
- 只有檔案被偷走時解不解得開
把這些差別大致整理成表,是這樣。
| 方式 | 設定檔被讀到 | 只有檔案被帶到別台 PC | 被同一台 PC 的其他使用者讀到 | 以相同使用者權限執行的程式碼 |
|---|---|---|---|---|
| 明文 | 當場外洩 | 直接外洩 | 直接外洩 | 當然讀得到 |
| 自製加密 + 金鑰放在同一份設定 / 執行檔裡 | 幾乎等於外洩 | 幾乎等於外洩 | 幾乎等於外洩 | 當然解得開 |
DPAPI + CurrentUser |
光有檔案讀不出來 | 通常很難解密 | 通常很難解密 | 解得開 |
DPAPI + LocalMachine |
光有檔案讀不出來 | 在那台 PC 以外通常很難解密 | 只要在同一台 PC 上就能廣泛解密 | 解得開 |
這裡重要的是,DPAPI 把「讀得到檔案」和「用得了機密」分開這一點。
flowchart TB
CT["密文<br/>會進到設定檔 / DB 的欄 / 調查用 ZIP<br/>= 可能被帶出去的東西"]
subgraph WIN["解密需要的東西 ── 留在 OS 這一側,不包含在密文裡"]
UMK["使用者的主要金鑰<br/>以 CurrentUser 保護時"]
MMK["電腦的主要金鑰<br/>以 LocalMachine 保護時"]
end
CT -->|"以 CurrentUser 保護"| UMK
CT -->|"以 LocalMachine 保護"| MMK
UMK --> A1["以那位使用者身分執行的程式碼<br/>→ 解得開"]
UMK --> A2["同一台 PC 的其他使用者<br/>→ 解不開"]
MMK --> B1["同一台 PC 上的程式碼<br/>→ 換一位使用者也解得開"]
UMK --> C1["只把密文複製到另一台 PC<br/>→ 解不開"]
MMK --> C1
UMK --> C2["連漫遊使用者設定檔一起移動<br/>→ 金鑰材料也跟著走,所以解得開(6.5 節)"]
圖 3: 可以被帶出去的只有密文,解密需要的主要金鑰留在 OS 這一側。不過 LocalMachine 涵蓋的範圍是整台 PC,擋不住同一台 PC 的其他使用者
在明文的情況下,這兩件事是同一件。 檔案讀得到,機密也就讀得到。
但用 DPAPI 時,至少在 CurrentUser 之下,解密必須
- 以那個 Windows 使用者的身分
- 在那個 Windows 的脈絡裡
- 透過 OS 的保護機制
才能完成。
這個差別,在出事的現場相當關鍵。
3.2.「就算這樣,同一個使用者不是照樣解得開嗎?」——確實如此
這一點應該老實寫清楚,不該含糊帶過。
以相同使用者權限執行的程式碼,原則上解得開那個使用者解得開的東西。
也就是說,DPAPI 的主要目的並不是應付下列狀況。
- 那台電腦已經遭惡意軟體入侵
- 攻擊者能以那個使用者的身分執行程式碼
- 已經在電腦管理者層級被完全接管
在這些狀況下,既然應用程式自己解得開,攻擊者的程式碼當然也解得開。 這時候說「可是我們有加密」,並不怎麼可靠。
DPAPI 派得上用場的,主要是「檔案外流、放錯位置、離線帶出、其他使用者來讀取」這一側。
弄錯這條線,就會同時出現
- 低估守得住的部分,於是不用它
- 高估守不住的部分,於是放心
這兩種情況。兩者都是不起眼卻很危險。
flowchart TB
accTitle: DPAPI 派得上用場與派不上用場的兩側
accDescr: 說明 DPAPI 派得上用場的是檔案外流、放錯位置、離線帶出、其他使用者來讀取這一側,對以相同使用者權限執行的攻擊程式碼與已遭入侵的電腦則派不上用場,這條界線的圖。
dp2["DPAPI 的守備範圍"] -->|"派得上用場的一側"| eff1["外流、放錯位置、其他使用者"]
dp2 -.->|"派不上用場的一側"| noef1["相同使用者權限的程式碼"]
dp2 -.-> mis1["弄錯就會評估失準"]
圖 4: 不知道派得上用場與派不上用場的界線,就會落入不用它的低估,或太過放心的高估。
3.3. 那到底好在哪裡
DPAPI 的好處用一句話講,就是
「能把機密本身,從設定檔的可讀性中切開」
這件事。
舉例來說,在下列這些事故裡,明文和 DPAPI 就有差別。
- 使用者把設定檔寄給了技術支援
- 調查用 ZIP 裡包進了設定檔
- 只有設定檔從備份裡外流
- 被複製到共用資料夾上
- 開發者只看得到密文,讀不出內容
這是相當實際的好處。 不必把攻擊者想像成電影裡的超人,也能縮小日常出包時的災情範圍。
flowchart TB
accTitle: 災情範圍縮小的機制
accDescr: 說明就算發生誤寄給技術支援、調查用 ZIP、備份外流這類日常事故而讓檔案流到外面,出去的也只有密文,不會直接變成機密外洩,災情範圍因此縮小的圖。
ac1["誤寄、調查 ZIP、備份外流"] --> out3["流到外面的只有密文"]
out3 --> sm2["不必直接變成機密外洩"]
sm2 --> rad1["日常出包的災情範圍變小"]
圖 5: 檔案流到外面的事故擋不住,但可以把流出去的東西換成密文。
4. DPAPI 為什麼剛剛好
在 Windows 上處理要存在本機的機密時,DPAPI 在實務上剛剛好的理由如下。
4.1. 能把金鑰管理集中到 OS
自己產生 AES 金鑰、保存它、設定權限、輪替它、思考外洩時的影響,還要加上竄改偵測。 這比想像中沉重。而且隨手做的話,多半就是把金鑰放在同一個地方收工。
用了 DPAPI,就能把「加密金鑰要怎麼產生、放在哪裡」這個問題,從應用程式的實作裡切開。
從這層意義上看,DPAPI 與其看成「挑選加密演算法的 API」,不如看成「把金鑰管理委由 OS 處理的 API」, 更接近它的本質。
flowchart TB
accTitle: 看待 DPAPI 的本質觀點
accDescr: 說明 DPAPI 與其看成挑選加密演算法的 API,不如看成把金鑰要怎麼產生、放在哪裡這個金鑰管理問題委由 OS 處理的 API,更接近它本質的圖。
v1["挑選演算法的 API"] -.->|"不是這個觀點"| dpv1["DPAPI"]
v2["把金鑰管理委由 OS 處理的 API"] -->|"這個觀點更接近本質"| dpv1
dpv1 --> off1["能把金鑰的產生與保管問題從實作裡切開"]
圖 6: 把 DPAPI 看成金鑰管理的委由對象,就能看清應用程式可以切開哪些東西。
4.2. 能把解密主體綁定到 Windows 使用者或電腦
一般的桌面應用程式,多半選 CurrentUser 就好。
- 那位使用者已經登入
- 處理是在那位使用者的脈絡下執行
可以在這兩個前提下解密。
因此,就能得到只把密文複製到別台 PC,也不容易直接使用的性質。
4.3. 連竄改偵測也容易一併涵蓋
自製加密最常見的問題是, 以為「用 AES 加密就結束了」,結果忘了竄改偵測。
DPAPI 對加密資料本身就帶有完整性保護, 因此在實務上有一個好處:連密文被擅自改寫時的偵測,都能交給 OS 這一側的機制。
flowchart TB
accTitle: 竄改偵測上的差別
accDescr: 說明自製加密容易以為用 AES 加密就結束而忘了竄改偵測,DPAPI 則對加密資料帶有完整性保護,連密文改寫的偵測都能交給 OS 這一側機制的圖。
diy1["自製加密"] -.-> forget1["容易忘掉竄改偵測"]
dpi1["DPAPI"] --> integ1["帶有完整性保護"]
integ1 --> det2["連改寫的偵測都交給 OS 這一側"]
圖 7: 不只加密,連竄改偵測都能交給 OS 這一側的機制,這是 DPAPI 的優點。
4.4. 從 C# / .NET 可以直接了當地使用
用 C# 的話,直接使用 System.Security.Cryptography.ProtectedData 就好。
不必額外加進別的函式庫,這對 Windows 專用的應用程式來說相當省事。
5. DPAPI 守得住的與守不住的
這裡明確分開比較安全。
5.1. 變得比較守得住的
DPAPI 至少在下列場面是有效的。
- 設定檔的明文外洩
- 檔案被帶到別台 PC
- 同一台 PC 的其他使用者來讀取(以
CurrentUser為前提) - 以備份或郵件附件的形式外流
- 開發與維護實務中「不小心就讀得到」的狀態
5.2. 守不住,或防禦力較弱的
另一方面,下列狀況最好不要過度信任。
- 以相同使用者權限執行的攻擊程式碼
- 電腦本身遭到完全入侵
- 以系統管理員權限被接管
- 應用程式解密之後留在記憶體裡的明文
- 發給所有用戶端的共用長期機密
最後這項「所有用戶端共用的長期機密」特別重要。
例如,
- 對所有客戶嵌入同一把 API 金鑰
- 所有電腦持有同一組共用密碼
- 發出只靠用戶端就能完成的固定解密金鑰
這類設計,只要從其中 1 台被取走,就很容易波及全體。 道理很簡單:只要有 1 台上的應用程式解得開,那份機密就取得出來。
DPAPI 對於「讓那個保存位置比明文好一些」是有效的, 但它並不能正當化那些本來就不該放在用戶端的機密。
flowchart TB
accTitle: 共用長期機密波及全體的機制
accDescr: 說明對所有用戶端發出共用的長期機密後,只要有 1 台上的應用程式解得開,那份機密就取得出來,因此從 1 台被取走就容易波及全體的圖。
com1["所有用戶端共用的長期機密"] --> one4["有 1 台上的應用程式解得開"]
one4 --> ext1["就能從那 1 台把機密取出來"]
ext1 --> all1["波及全體"]
com1 -.-> np2["用 DPAPI 保存也不是根本解法"]
圖 8: 共用機密只要 1 台淪陷就波及全體,所以要重新檢視的不是保存方式,而是放在哪裡。
這類機密的正途,不是在保存方式上動腦筋,而是往下面這些方向移走。
- 放到伺服器端
- 用戶端只持有權杖
- 改成每位使用者各自的認證資訊
- 改成有期限的權杖
6. CurrentUser 與 LocalMachine 的取捨
這裡相當重要。隨手亂選,意義就變了。
6.1. 基本用 CurrentUser
一般的 Windows 桌面應用程式,先以 CurrentUser 為基本來考慮。
適合的例子:
- WPF / WinForms / WinUI 這類給使用者用的桌面應用程式
- 每位使用者各自持有設定或認證資訊的應用程式
- 把設定放在
%LocalAppData%或%AppData%底下的應用程式
這種情況下,比較容易把它當成「那個 Windows 使用者的機密」來處理。
6.2. LocalMachine 的適用範圍相當有限
LocalMachine 看起來很方便,但對一般的桌面應用程式來說範圍太寬了。
適合的情形,例如下列這些。
- 受信任的單一用途機器上的 Windows 服務
- 只在那台機器上的特定處理程序裡使用的機密
- 必須跨越登入的使用者、在同一台電腦上共用的情況
不過要注意的地方也很沉重。
- 那台 PC 上執行的處理程序都能廣泛解密
- 在共用電腦、RDS、跳板電腦、有多位使用者登入的環境很容易變危險
- 若以「反正大家都能用比較輕鬆」來選,之後多半會出事
而想選 LocalMachine 的動機,大致就是這 3 個。
- 切換使用者之後也讀得到
- 從服務也讀得到
- 能動就方便
這些都是「輕鬆」,而不是「守得住」。
在一般的桌面應用程式選 LocalMachine,等於把解密的可能性擴大到那台 PC 上的其他處理程序,意義差很多。
flowchart TB
accTitle: 為了輕鬆而選 LocalMachine 會怎樣
accDescr: 說明因為跨使用者也讀得到、從服務也讀得到這類輕鬆理由而選 LocalMachine,會把解密的可能性擴大到那台 PC 上的其他處理程序,變成和守得住不同的狀態的圖。
ease1["跨使用者、支援服務都很輕鬆"] --> pick4["選了 LocalMachine"]
pick4 --> wide1["解密的可能性擴大到 PC 上的其他處理程序"]
wide1 -.-> not1["那是輕鬆,不是守得住"]
圖 9: 為了輕鬆而選 LocalMachine,解得開的範圍就擴大到整台 PC。
6.3. 猶豫的時候這樣想
- 一般的 UI 應用程式 ->
CurrentUser - 真的需要以機器為單位保護的特殊情形 ->
LocalMachine - 需要任何使用者都解得開,但電腦上還有其他使用者 -> 多半該從設計重新檢視
6.4. 牽扯到服務或 impersonation 時要多留意
一旦牽扯到 Windows 服務或 impersonation,CurrentUser 的意義就變得比較重。
- 執行帳戶是誰
- 那個帳戶的使用者設定檔有沒有載入
- 解密的時機是在哪個脈絡下
這幾點只要對不上,就很容易變成「加密得起來,卻解不開」。
服務用途沒辦法用「先用 CurrentUser 再說」草草帶過。
在 impersonation 的情況下,Microsoft Learn 也明確記載了一個典型的失敗:「Key not valid for use in specified state.」。DPAPI 把金鑰資料保存在使用者設定檔裡,因此設定檔沒有載入就解不開。在 impersonate 之前,必須先把目標使用者的設定檔載入。
flowchart TB
accTitle: impersonation 時的典型失敗
accDescr: 說明 DPAPI 把金鑰資料保存在使用者設定檔裡,因此在設定檔尚未載入的狀態下 impersonate 再解密會出錯,必須先把目標使用者的設定檔載入的圖。
imp1["未載入就 impersonate"] --> ferr1["解密因錯誤而失敗"]
ld1["先把設定檔載入"] --> okp1["impersonate 之後仍解得開"]
imp1 -.-> whyp1["金鑰在設定檔裡"]
圖 10: 金鑰在使用者設定檔這一側,所以 impersonate 之前必須先載入設定檔。
6.5. 先知道維運上會「解不開」的情況
比起加密本身,解不開的事故在實務上更痛。DPAPI 是把能解密的主體綁定到 Windows 使用者或電腦的機制,所以那層綁定一斷,資料就讀不出來。
以下 5 種是要先知道的。
| 情況 | 會發生什麼 | 怎麼準備 |
|---|---|---|
| 由系統管理員重設密碼 | 綁在使用者密碼上的保護會脫落,有時會變成存取不了以 DPAPI 保護的資料。Microsoft 的支援資訊裡也記載了系統管理員重設密碼之後無法存取 DPAPI 資料這個現象 | 把機密設計成「可以重新取得」。解密失敗時引導到重新輸入 |
| 重新建立使用者設定檔 | 新的設定檔持有不同的金鑰材料,因此解不開以前的密文 | 讓設定檔帶版本,並且不要把解密失敗變成異常終止 |
| 只把密文複製到別台 PC | 解密需要的金鑰材料在使用者設定檔那一側,所以只帶著 CurrentUser 的密文過去也讀不出來(這是 3.1. 提到的「強項」的另一面) |
以「每台電腦各自重新保存一次」為前提來設計 |
| 漫遊使用者設定檔 | 這種情況讀得到。金鑰材料會跟著設定檔一起移動,Microsoft Learn 也明確寫著「擁有漫遊使用者設定檔的使用者可以從網路上的另一台電腦解密」。若把它和上一列同等看待,遷移程序裡就會不必要地重建認證資訊 | 不要斷定「換一台 PC 就讀不到」。先查看有沒有使用漫遊設定檔,再決定遷移程序 |
| 變更服務的執行帳戶 | 保護當下和解密當下的執行帳戶若不同,CurrentUser 就讀不出來 |
把「變更帳戶時重新保護一次」的程序納入維運 |
總之,寫程式時就要以ProtectedData.Unprotect 可能失敗為前提。解密失敗會拋出 CryptographicException,所以在那裡把例外攔下來,收攏到重新輸入的操作動線上。
using System;
using System.Security.Cryptography;
using System.Text;
// protectedBase64: 從設定檔讀出來的密文 (Base64)
// entropy: 傳入與保護時相同的值。也可以是 null
static bool TryUnprotect(string protectedBase64, byte[]? entropy, out string plaintext)
{
plaintext = string.Empty;
try
{
byte[] plainBytes = ProtectedData.Unprotect(
Convert.FromBase64String(protectedBase64),
optionalEntropy: entropy,
scope: DataProtectionScope.CurrentUser);
plaintext = Encoding.UTF8.GetString(plainBytes);
return true;
}
catch (CryptographicException)
{
// 解不開 = 環境很可能已經改變。
// 這裡不要讓程式當掉,交由呼叫端收攏到重新輸入的操作動線
return false;
}
catch (FormatException)
{
// Base64 本身已經損壞的情況
return false;
}
}
以「明明加密了卻解不開」來詢問的案例,大致都落在這張表的某一列。
flowchart TB
accTitle: 以解密會失敗為前提的程式流程
accDescr: 說明寫程式時要以 ProtectedData.Unprotect 可能因環境改變而失敗為前提,攔下 CryptographicException 不讓程式異常終止,並收攏到重新輸入的操作動線的流程圖。
upx1["嘗試 Unprotect"] -->|"成功"| use2["使用機密"]
upx1 -->|"因例外而失敗"| ctc1["攔下例外,不讓程式當掉"]
ctc1 --> rein1["收攏到重新輸入的操作動線"]
upx1 -.-> why2["綁定一斷就可能失敗"]
圖 11: 以解密可能失敗為前提來寫,把失敗接到重新輸入而不是異常終止。
7. 實作的最低限度方針
在 Windows 應用程式上只要做到「不再把設定檔放成明文」,設計不必弄得太複雜。 不過,有幾個點不想漏掉。
7.1. 只保護機密本身
比起把整份設定通通加密,先只保護機密的項目會比較好處理。
例如,像下面這樣分開。
- 伺服器 URL
- 使用者名稱
- DB 名稱
- 功能旗標
這些多半維持明文也沒關係。
另一方面,
- 密碼
- API 權杖
- 更新權杖(refresh token)
- 共用資料夾的認證資訊
這些就是保護對象。
照這樣分開之後,
- 設定容易編輯
- 差異容易檢查
- 哪裡是機密很明確
- 整體維運單純
就都成立了。
flowchart TB
accTitle: 只保護機密項目的區分方式
accDescr: 說明不要把整份設定通通加密,而是讓 URL 與使用者名稱等維持明文,只保護密碼與權杖等機密項目,這樣既容易編輯也能明確標出哪裡是機密的圖。
cfg2["設定檔"] -->|"維持明文"| pl1["URL、使用者名稱、旗標等"]
cfg2 -->|"加以保護"| sc1["密碼、權杖等"]
sc1 --> mr1["哪裡是機密很明確,維運也單純"]
圖 12: 不是整份加密,而是只保護機密項目,好處理和安全就能兼顧。
7.2. 保存位置以 per-user 為基本
一般的桌面應用程式,保存位置以 per-user 的地方為基本。
%LocalAppData%\Vendor\App\settings.json%AppData%\Vendor\App\settings.json
至少,不要隨手放在安裝資料夾底下,或容易被共用的位置。
就算用 DPAPI 保護了,保存位置的 ACL 若很隨便, 就會變成「密文被讀到」「設定結構被看光」「維運失誤照樣發生」 這種局面。防禦不要只有一層,疊起來才管用。
7.3. optionalEntropy 不是萬能的第二把金鑰
ProtectedData 可以傳入 optionalEntropy。
這個參數很方便,但它不是「只要埋進執行檔就變安全的魔法第二把金鑰」。
- 放在同一個檔案裡就構不成機密
- 以固定值埋進執行檔,也稱不上是強的機密
- 即使如此,它對用途識別與防止誤用還是有幫助
在實務上,把
- 應用程式名稱
- 用途名稱
- 版本識別字
當成固定的位元組序列傳進去,用來「不要誤把其他用途的密文收下來」,這樣的定位剛剛好。
flowchart TB
accTitle: optionalEntropy 的正確定位
accDescr: 說明 optionalEntropy 不是只要埋進執行檔就變安全的魔法第二把金鑰,而是把應用程式名稱與用途名稱當成固定位元組序列傳入,用於避免誤收其他用途密文的用途識別,這樣的定位剛剛好的圖。
ent1["optionalEntropy"] -.->|"不要有這種期待"| key2["魔法的第二把金鑰"]
ent1 -->|"這種用法剛剛好"| tag1["應用程式名稱、用途名稱的識別字"]
tag1 --> guard1["不會誤收其他用途的密文"]
圖 13: entropy 不是私密金鑰,而是用來做用途識別與防止誤用的標籤。
7.4. 不代表密文可以放進 Git
這一點也是不起眼卻很重要。
DPAPI 的密文遠比明文好, 但這不代表整份設定檔可以放進儲存庫。
理由很單純,
- 密文會留很久
- 說不定哪天同一台電腦或同一個脈絡又重現了
- 檔案裡還有機密以外的資訊
- 會養成「反正有保護,隨便處理也行」的文化
就是這些。
「比明文好」和 「放到哪裡都安全」 完全是兩回事。
flowchart TB
accTitle: 就算是密文也不放進 Git 的理由
accDescr: 說明 DPAPI 的密文雖然比明文好,但放進儲存庫會留很久、還包含機密以外的資訊,也會養成有保護就隨便處理的文化,因此和放到哪裡都安全是兩回事的圖。
enc1["DPAPI 的密文"] -->|"比明文好"| bet2["比較禁得起事故"]
enc1 -.->|"即使如此"| git1["也不要放進儲存庫"]
git1 --> rs1["留很久、還有其他資訊、文化會鬆掉"]
圖 14: 「比明文好」不能成為隨便挑放置位置的理由。
7.5. 不要寫進日誌
意外常見的是,解密之後寫進日誌,結果前功盡棄的情況。
- 連線失敗時把整段連線字串輸出出來
- API 回 401 時把 Authorization 標頭留下來
- 例外訊息裡混進機密
做了這些,就算不再把設定檔放成明文,日誌最後還是變成明文倉庫。 說來悲哀,但這相當貼近實務。
8. C# / .NET 的最小實作範例
8.1. 首先,需要加入參考
ProtectedData 看起來像是內建在 BCL 裡,但它「從哪裡來」會隨目標框架而不同。在這裡卡住的話,ProtectedData 這個型別名稱就無法解析。
| 目標框架 | 需要做的事 | 提供來源 |
|---|---|---|
| .NET Framework | 在專案中加入 System.Security 組件參考 |
System.Security.dll |
| .NET Core / .NET 5 以後(含 .NET 6 / 8) | 加入 NuGet 的 System.Security.Cryptography.ProtectedData 套件 |
System.Security.Cryptography.ProtectedData.dll |
這個套件並不包含在 .NET Core / .NET 5 以後的任何共用框架裡。即使是 net8.0-windows 這種以 Windows 為目標的框架,也必須明確加入參考。
dotnet add package System.Security.Cryptography.ProtectedData
還有 1 點,比實作更早該知道的事。
ProtectedData 是 Windows 專用的。由於它相依於 DPAPI,在 Windows 以外平台的 .NET 上呼叫,會拋出 PlatformNotSupportedException。若程式碼庫本來就以跨平台為前提,就照 10.1. 所述,一開始就採用別的設計。
flowchart TB
accTitle: 使用 ProtectedData 之前要確認的事
accDescr: 說明使用 ProtectedData 前必須依目標框架加入參考,沒加入就無法解析型別名稱,而且它是 Windows 專用的,在 Windows 以外呼叫會產生例外,這兩個實作前該確認的前提的圖。
use3["想使用 ProtectedData"] --> ref1["依目標框架加入參考"]
ref1 -.->|"不加入的話"| unres1["型別名稱無法解析"]
use3 --> winonly1["先理解它是 Windows 專用"]
winonly1 -.->|"在 Windows 以外呼叫"| pnse1["執行階段會出現例外"]
圖 15: 實作之前,先把加入參考與 Windows 專用這兩個前提弄清楚。
8.2. 最小實作
以下是把要存進設定檔的字串以 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 保存也不是根本解法。 理由,以及該把它移到哪裡去,都梳理在 5.2. 了。
9.3. 因為「輕鬆」而選 LocalMachine
這也真的很容易就這樣做。但那是「輕鬆」,不是「守得住」。 想選它的動機,以及選了之後什麼會擴大,都如 6.2. 所述。判斷上猶豫的話,請看 6.3. 的那 3 行。
9.4. 加上自製加密就安心
不用 DPAPI,改成
- 把 AES 金鑰寫死進原始碼
- 把 AES 金鑰放在設定檔的另一個項目
- 把「稍微混淆過的字串」當成金鑰
放進這類實作,效果大多很薄。
「不是明文」和「是安全的」之間,隔著相當大的鴻溝。
flowchart TB
accTitle: 靠自製加密安心的風險
accDescr: 說明把 AES 金鑰放進原始碼或設定檔的另一個項目、把混淆過的字串當成金鑰,這類自製加密效果很薄,不是明文與是安全的之間隔著很大鴻溝的圖。
hm1["把金鑰埋進程式碼或設定的自製加密"] --> npl1["不是明文的狀態"]
npl1 -.->|"中間隔著大鴻溝"| sfe1["安全的狀態"]
hm1 -.-> thin1["效果大多很薄"]
圖 16: 把金鑰放在同一個地方的自製加密,只做到「不是明文」,構不到「安全」。
10. DPAPI 不夠用的情況
DPAPI 很方便,但不是萬能。下列情況最好考慮別的選項。
10.1. 想在 Windows 以外也跑得起來
DPAPI / ProtectedData 是給 Windows 用的。
如果是跨平台的應用程式,就不能建立在這個前提上。
10.2. 想在多台機器、多位使用者之間處理同一份機密
想讓同一份密文在多台 PC 上都解得開,或想讓多位使用者共用,這類需求 已經離開了「綁定到那台電腦、那位使用者」的 DPAPI 擅長的領域。
這種情況下,應該考慮
- 伺服器端的機密管理
- 認證資訊基礎架構
- Windows 驗證 / 整合式驗證
- 給應用程式用的認證資訊存放區
等等符合需求的另一種設計。
10.3. 要保存的就是使用者認證資訊本身
如果想保存的明確就是
- 使用者名稱
- 密碼
的組合,那麼比起用 DPAPI 寫進自己的檔案,Windows 已經備好的認證資訊存放區更直接了當。這裡在實務上很常猶豫,所以放一張比較表。
| 觀點 | DPAPI (ProtectedData) |
Credential Locker (PasswordVault) |
Credential Manager (CredWrite / CredRead) |
|---|---|---|---|
| 保存的位置 | 自己決定的檔案(密文怎麼放由應用程式決定) | Windows 管理的認證資訊存放區 | Windows 管理的認證資訊存放區 |
| 能保存的東西 | 任意位元組序列(連線字串、權杖,或設定的一部分都可以) | 使用者名稱 + 密碼的組合 | 認證資訊(依種類而定的結構) |
| API | System.Security.Cryptography |
WinRT 的 Windows.Security.Credentials |
Win32 (wincred.h / Advapi32.dll) |
| 桌面應用程式能不能用 | 可以直接用 | 不只 WinUI,WPF / WinForms 也用得到(需要設定 WinRT API 的呼叫方式) | 可以直接用 |
| 同步 | 沒有 | 會透過 Microsoft 帳戶在多台電腦間漫遊 | 沒有(本機的使用者認證資訊集合) |
| 限制 | 實質上沒有 | 每個應用程式最多 20 筆。不適合放大量資料 | 綁在目前權杖的登入工作階段上 |
| 管理的主體 | 應用程式(保存位置與 ACL 都自己決定) | OS(不必自己設計保存位置) | OS(可以從控制台的認證管理員來管理) |
取捨的判斷基準是這樣。
- 要保存的是使用者名稱 + 密碼的組合,筆數也不多 -> Credential Locker / Credential Manager 是第一候選。保存位置的設計與 ACL 都不必自己扛
- 要保存的東西不是「使用者名稱 + 密碼」這種形式 -> 連線字串、API 權杖、更新權杖、設定檔的一部分這類東西,DPAPI 這一側比較直接了當。本文處理的正是這一側
- 想在多台電腦之間接續使用 -> Credential Locker 的漫遊派得上用場。DPAPI 的
CurrentUser反過來以「不會被接續」為優點,這裡的目的正好相反 - 筆數很多 / 資料很大 -> 會撞上 Credential Locker 的 20 筆限制。改用 DPAPI 收攏到自己的檔案
順帶一提,認證資訊存放區的內部同樣建立在 OS 的保護機制上,所以不是「比 DPAPI 安全」「DPAPI 比較差」這種排名關係。實務上是看要保存的東西是什麼形式,以及要不要漫遊來選。
而且無論選哪一個,只要是新的應用程式,都值得先評估 Windows Hello / 密碼金鑰(passkey)這類無密碼方式。如果根本不必持有長期密碼,那才是最強的做法。
本文的重心,始終是「讓 Windows 用戶端不再把設定檔放成明文」這條 DPAPI 的實務界線。
11. 實務上建議的優先順序
最後,實務上猶豫時照這個順序思考會比較好梳理。從上往下逐一評估,只有在條件不成立時才往下走。
flowchart TD
Q1{"能不能不讓電腦<br/>持有長期機密"}
Q1 -->|"可以"| A1["優先 1: 不持有<br/>(Windows 驗證、短命權杖)"]
Q1 -->|"不行"| Q2{"機密能不能<br/>按使用者分開"}
Q2 -->|"分不開"| A2["檢查是不是變成共用金鑰<br/>從設計重新檢視"]
Q2 -->|"分得開"| QF{"要保存的東西是不是<br/>使用者名稱 + 密碼的組合(10.3 節)"}
QF -->|"是這種組合,筆數也不多"| QR1{"需不需要在多台電腦間接續"}
QR1 -->|"需要"| QA{"是不是會透過 Microsoft 帳戶<br/>同步的電腦(10.3 節)"}
QA -->|"是"| CL2["使用 Credential Locker 的<br/>漫遊"]
QA -->|"網域 / 本機帳戶"| SRV
QR1 -->|"不需要"| CL["Credential Locker /<br/>Credential Manager"]
QF -->|"權杖等其他形式"| QR2{"需不需要在多台電腦間接續"}
QR2 -->|"需要"| SRV["DPAPI 接續不了。<br/>改由伺服器端管理(10.2 節)"]
QR2 -->|"不需要"| Q3{"要解開同一份機密的<br/>帳戶有幾個"}
Q3 -->|"1 個就夠(使用者本人,<br/>或專用的服務帳戶)"| A3["優先 3: DPAPI + CurrentUser<br/>無人執行時要查看設定檔<br/>有沒有載入(6.4 節)"]
Q3 -->|"必須從多個帳戶<br/>解密"| Q4{"能不能斷言其他使用者<br/>不會進來"}
Q4 -->|"可以斷言"| A4["優先 4: DPAPI + LocalMachine<br/>當成例外處理並留下依據"]
Q4 -->|"不能斷言"| A5["其他使用者也會解得開。<br/>該重新檢視的是驗證方式"]
圖 17: 保存方式的選定順序。先看機密的形式與要不要漫遊,再進到 DPAPI。LocalMachine 不是因為「輕鬆」而選,而是在其他選項都不成立時當成例外來選
優先 1: 根本不持有
- Windows 驗證
- 整合式驗證
- 互動式登入
- 由伺服器端保管機密
- 短命權杖
優先 2: 收攏成每位使用者各自的機密
- 比起共用機密,改用 per-user
- 比起長期固定的認證資訊,改用可更新的權杖
- 避免所有用戶端共用的金鑰
優先 3: 需要存在本機就用 DPAPI
- 通常用
CurrentUser - 保存位置用 per-user
- 只保護機密項目
- 不要寫進日誌
優先 4: LocalMachine 當成例外
- 是不是真的必須以機器為單位
- 那台電腦是不是不會有其他使用者進來
- 以服務設計來看是否妥當
12. 總結
在 Windows 應用程式必須把機密資訊存進設定檔時, 要避免的是以明文放著。
而且,對於
「反正金鑰總得存在某個地方,不是一樣嗎?」
這個疑問,實務上這樣回答比較好。
- 自製加密又把金鑰放在同一個地方的話,確實相當接近一樣
- DPAPI 並不一樣
- 能把金鑰管理集中到 OS
- 能把解密主體綁定到 Windows 使用者 / 電腦
- 能讓檔案單獨外流不至於直接變成機密外洩
- 不過
- 以相同使用者權限執行的程式碼
- 已被完全入侵的電腦
- 本來就不該放在用戶端的長期共用機密
這些它解決不了
總之,DPAPI 不是萬能的城牆。 但它至少有這樣的效果:把設定檔明文這片一眼看穿的窗玻璃,換成一扇最低限度像樣的窗。
在 Windows 用戶端的實務上,這個差別相當大。 先從不漏掉這一點開始,是最現實的做法。
flowchart TB
accTitle: DPAPI 的現實定位
accDescr: 說明 DPAPI 雖然不是萬能的城牆,但有把設定檔明文這片一眼看穿的窗玻璃換成一扇最低限度像樣的窗的效果,先從這裡開始才現實的圖。
glass1["明文設定 = 一眼看穿的窗玻璃"] -->|"換成 DPAPI"| win2["最低限度像樣的窗"]
win2 -.-> notwall1["不是萬能的城牆"]
win2 --> first1["先從這裡開始才現實"]
圖 18: DPAPI 不是城牆而是換窗,但在實務上這個差別最管用。
13. 參考資料
- 前一篇文章: https://comcomponent.com/zh-TW/blog/2026/03/14/001-windows-app-security-minimum-checklist/
- Microsoft Learn:
CryptProtectDatahttps://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata - Microsoft Learn:
ProtectedDatahttps://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.protecteddata?view=windowsdesktop-10.0 - Microsoft Learn:
DataProtectionScopehttps://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
- Microsoft Learn:
CredWrite(Windows Credential Manager 的 Win32 API) https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credwritew - NuGet: System.Security.Cryptography.ProtectedData https://www.nuget.org/packages/System.Security.Cryptography.ProtectedData
- Microsoft 支援: 系統管理員重設密碼之後無法存取 DPAPI 資料的現象 https://support.microsoft.com/en-us/topic/you-cannot-access-dpapi-data-after-an-administrator-resets-your-password-on-a-windows-server-2012-based-domain-controller-4aa890cd-12b5-fe5c-9e68-06244e70673d
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
在 Windows 應用程式中只分離「需要系統管理員權限的處理」的具體寫法
在 Windows 應用程式中讓 UI 維持 asInvoker,只把需要系統管理員權限的處理分離到 helper EXE。本文把這套設計連同 UAC、runas、具名管道、輸入驗證一起具體梳理清楚。
Windows 應用程式開發的最低限度資安檢核表
以檢核表形式整理 WPF / WinForms / WinUI / C++ / C# 業務應用程式在權限、簽署、更新、機密資訊、HTTPS、輸入驗證、DLL 載入與日誌方面的基本要點。
自行開發的 Windows 應用程式被當成病毒處理時 ── Microsoft Defender 誤判的因應方式,以及與效能影響的相處之道
整理自行開發的 Windows 應用程式被 Microsoft Defender 誤判時的正規因應方式。從現代防毒機制的原理、向 Microsoft 回報誤判、從隔離還原,到排除設定的正確做法與風險,一併解說。
MAX_PATH 與 Windows 路徑・檔案名稱的陷阱 ── 260 字元限制、保留名稱、結尾句點、大小寫
本文整理「找不到檔案」這類問題的常見原因──路徑與檔案名稱的限制。內容涵蓋 MAX_PATH=260 字元的組成、透過 LongPathsEnabled 啟用長路徑、CON 等保留名稱、結尾句點的正規化,直到 Path.Combine 的陷阱。
正確處理 Windows 偽裝權杖 - 執行緒層級的權限借用與安全還原方式
本文整理 Windows 偽裝權杖的實務安全處理方式,涵蓋存取權杖、主要權杖、執行緒權杖、偽裝層級、RevertToSelf,以及 .NET 的 WindowsIdentity.RunImpersonated。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
認證資訊的保存方式、每位使用者的設定存放位置,一直到日誌要怎麼寫,全都牽涉到 Windows 應用程式的整體設計,因此和 Windows 應用程式開發這個主題特別合用。
技術諮詢 & 設計審查
如果想從重新檢視既有應用程式的明文設定,或梳理 DPAPI 與 Credential Locker 的取捨開始著手,這個主題很適合以技術諮詢、設計審查的形式推進。
常見問題
整理諮詢這個主題時常見的問題。
- DPAPI 是什麼?
- DPAPI(Data Protection API)是 Windows 提供的資料保護機制,把加密金鑰的管理交給 OS,並把能解密的主體綁定到那個 Windows 使用者或那台電腦。從 C# / .NET 可以透過 System.Security.Cryptography.ProtectedData 類別使用,不必額外加進別的函式庫。與其把它看成挑選加密演算法的 API,不如看成把金鑰管理委由 OS 處理的 API,更接近它的本質。對於要存進設定檔的密碼或 API 權杖,這是避免以明文放著的現實選項。
- 反正金鑰總得存在某個地方,明文和 DPAPI 不是一樣嗎?
- 並不一樣。如果是自製的 AES 加密,又把金鑰放在同一個應用程式或同一份設定檔裡,那確實相當接近明文;但 DPAPI 把金鑰管理集中到 OS,並把能解密的主體綁定到 Windows 使用者或電腦。結果是,面對設定檔單獨外流、被帶到別台 PC、誤寄、備份外流、混進儲存庫這類事故時,防禦力差很多。DPAPI 能把「讀得到檔案」和「用得了機密」分開,這正是它和明文最關鍵的差別。
- DPAPI 守不住的是哪些東西?
- 以相同使用者權限執行的程式碼,原則上解得開那個使用者解得開的東西。因此,電腦已遭惡意軟體入侵的狀況、被以系統管理員權限接管的狀況,以及解密之後留在記憶體裡的明文,都守不住。另外,發給所有用戶端的共用長期機密,只要從其中一台被取走就容易波及全體,用 DPAPI 保存也不是根本解法。DPAPI 派得上用場的,主要是檔案外流、放錯位置、離線帶出、其他使用者來讀取這幾側。
- DataProtectionScope 的 CurrentUser 與 LocalMachine 該選哪一個?
- 一般的 Windows 桌面應用程式以 CurrentUser 為基本。這樣可以把它當成那個使用者的機密來處理,而且只把密文複製到別台 PC 也不容易直接使用。LocalMachine 因為那台 PC 上執行的處理程序都能廣泛解密,在共用電腦或有多位使用者登入的環境很容易變危險,用途相當有限,大致只剩下受信任的單一用途機器上的 Windows 服務之類。若因為「大家都能用比較輕鬆」而選 LocalMachine,之後多半會出事。