引用本文(DOI: 10.5281/zenodo.21615468)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《Windows 应用的敏感信息存储 - 用 DPAPI 摆脱明文配置》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615468 https://comcomponent.com/zh-CN/blog/2026/03/16/000-windows-app-secret-storage-best-practices-dpapi/
- DOI(最新版本)
- 10.5281/zenodo.21615468
- DOI(此版本)
- 10.5281/zenodo.22281985
在上一篇“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 把密钥管理交给操作系统,并把能够解密的主体绑定到“那个 Windows 用户”或“那台计算机”上
- 结果是,面对配置文件单独外泄、被带到别的电脑、误发邮件、备份外泄、混进代码仓库这类事故时,防护强度差别很大。
也就是说,只看“密钥总在某个地方”这个抽象说法时两者看起来一样,但“谁、在什么情境下、能多轻松地用到它”完全不同。
把钥匙放在门口地垫下面,和到管理室核验身份之后才领到钥匙,硬说成一回事,未免有点粗糙。
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 点决定。
- 密钥是不是由应用直接持有
- 密钥绑定在哪个主体上
- 只有文件被偷走时能不能解密
把这些差异粗略整理成表,大致如下。
| 方式 | 配置文件被读到 | 只有文件被带到别的电脑 | 被同一台电脑的其他用户读到 | 以相同用户权限运行的代码 |
|---|---|---|---|---|
| 明文 | 当场外泄 | 直接外泄 | 直接外泄 | 当然读得到 |
| 自制加密 + 密钥放在同一份配置 / 同一个可执行文件里 | 相当容易泄露 | 相当容易泄露 | 相当容易泄露 | 当然解得开 |
DPAPI + CurrentUser |
仅凭文件无法立即读取 | 通常难以解密 | 通常难以解密 | 可以解密 |
DPAPI + LocalMachine |
仅凭文件无法立即读取 | 在那台电脑以外通常难以解密 | 在同一台电脑上可以广泛解密 | 可以解密 |
这里的关键是,DPAPI 把“能读到文件”和“能用到敏感信息”分开了。
flowchart TB
CT["密文<br/>会进入配置文件 / 数据库的列 / 排查用 ZIP<br/>= 有可能被带走的东西"]
subgraph WIN["解密所需的东西 ── 留在操作系统一侧,不包含在密文里"]
UMK["用户的主密钥<br/>用 CurrentUser 保护时"]
MMK["计算机的主密钥<br/>用 LocalMachine 保护时"]
end
CT -->|"用 CurrentUser 保护"| UMK
CT -->|"用 LocalMachine 保护"| MMK
UMK --> A1["以该用户身份运行的代码<br/>→ 可以解密"]
UMK --> A2["同一台电脑的其他用户<br/>→ 无法解密"]
MMK --> B1["同一台电脑上的代码<br/>→ 换个用户也能解密"]
UMK --> C1["只把密文复制到别的电脑<br/>→ 无法解密"]
MMK --> C1
UMK --> C2["连同漫游用户配置文件一起迁移<br/>→ 密钥材料也一起移动,所以可以解密(6.5 节)"]
图3:可以被带走的只有密文,解密所需的主密钥留在操作系统一侧。不过 LocalMachine 的作用范围是整台电脑,挡不住同一台电脑上的其他用户
在明文下,这两件事是一回事。文件能读到,敏感信息也就读到了。
但在 DPAPI 下,至少 CurrentUser 的场景需要满足:
- 以该 Windows 用户的身份
- 在该 Windows 的上下文中
- 通过操作系统的保护机制
才能解密。
这个差别,在事故现场相当关键。
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. 可以把密钥管理交给操作系统
自己生成 AES 密钥、保存它、给它配好权限、做轮换、评估泄露时的影响,还要加上篡改检测。这比想象中更重。而且做得潦草的话,多半就是把密钥放在同一个地方了事。
用 DPAPI,就能把“加密密钥怎么生成、放在哪里”这个问题从应用实现中剥离出去。
从这个意义上说,与其把 DPAPI 看成“挑选加密算法的 API”,不如看成“把密钥管理委托给操作系统的 API”,这更接近本质。
flowchart TB
accTitle: 看待 DPAPI 的本质视角
accDescr: DPAPI 不是挑选加密算法的 API,而是把密钥怎么生成、放在哪里这个密钥管理问题委托给操作系统的 API,本图展示这个更接近本质的视角。
v1["挑选算法的 API"] -.->|"不是这个视角"| dpv1["DPAPI"]
v2["把密钥管理委托给操作系统的 API"] -->|"这个视角更接近本质"| dpv1
dpv1 --> off1["密钥的生成与保管问题可以从实现中剥离"]
图6:把 DPAPI 看成密钥管理的托管方,就能看清应用可以剥离掉什么。
4.2. 能把解密主体绑定到 Windows 用户或计算机
一般的桌面应用,多数场景选 CurrentUser 就可以。
- 该用户已经登录
- 处理在该用户的上下文中运行
解密以上面这两点为前提。
因此,就能得到只把密文复制到别的电脑也难以直接使用这种性质。
4.3. 容易把篡改检测一并纳入
自制加密常见的毛病是,认为“用 AES 加密完就结束了”,于是忘掉篡改检测。
DPAPI 对加密数据本身带有完整性保护,因此密文被擅自改写时的检测也容易一并交给操作系统的机制,这是实务上的一个好处。
flowchart TB
accTitle: 篡改检测上的差别
accDescr: 自制加密容易认为用 AES 加密完就结束而忘掉篡改检测,而 DPAPI 对加密数据带有完整性保护,密文被改写的检测也能交给操作系统的机制,本图展示这一差别。
diy1["自制加密"] -.-> forget1["容易忘掉篡改检测"]
dpi1["DPAPI"] --> integ1["带有完整性保护"]
integ1 --> det2["连改写的检测也交给操作系统一侧"]
图7:不只是加密,连篡改检测都能交给操作系统的机制,这是 DPAPI 的优点。
4.4. 从 C# / .NET 可以直接使用
用 C# 就能直接使用 System.Security.Cryptography.ProtectedData。不用额外引入其他库,这一点对 Windows 专用应用来说帮助不小。
5. DPAPI 守得住什么,守不住什么
这里最好明确区分开,这样更安全。
5.1. 变得容易守住的
DPAPI 至少在下面这些场景是有效的。
- 配置文件的明文泄露
- 把文件带到别的电脑
- 同一台电脑上其他用户的读取(以
CurrentUser为前提) - 以备份或邮件附件形式外泄
- 开发与运维现场“一不小心就被读到”的状态
5.2. 守不住或者防护很弱的
另一方面,在下面这些局面下最好不要过度信任它。
- 以相同用户权限运行的攻击代码
- 终端本身被完全入侵
- 被以管理员权限接管
- 应用解密之后留在内存里的明文
- 向所有客户端统一下发的长期敏感信息
最后一条“所有客户端共用的长期敏感信息”尤其重要。
比如:
- 给所有客户内嵌同一个 API 密钥
- 所有终端使用同一个共享密码
- 下发只靠客户端就能闭环的固定解密密钥
这类设计只要从任何一台设备上被取走,就很容易波及全体。道理很简单,只要有任何一台设备上的应用能解密,那份敏感信息就能被取出来。
DPAPI 对于“把保存方式做得比明文强”是有效的,但它并不能为本来就不该放在客户端的敏感信息背书。
flowchart TB
accTitle: 共用长期敏感信息的波及原理
accDescr: 向所有客户端下发共用的长期敏感信息后,只要任何一台设备上的应用能解密,那份信息就能被取出,因此一台被攻破就容易波及全体,本图展示这一原理。
com1["所有客户端共用的长期敏感信息"] --> one4["某一台设备上的应用能解密"]
one4 --> ext1["就能从那一台设备取出敏感信息"]
ext1 --> all1["波及全体"]
com1 -.-> np2["用 DPAPI 保存也不是根本解决办法"]
图8:共用敏感信息会因一台失守而波及全体,所以要重审的不是保存方式,而是存放位置。
对这类敏感信息,与其在保存方式上做文章,不如按下面的方向把它挪走才是正路。
- 放到服务端
- 客户端只持有令牌
- 改成按用户区分的凭据
- 改成带有效期的令牌
6. CurrentUser 与 LocalMachine 的取舍
这里相当重要。随便选,含义就变了。
6.1. 基本用 CurrentUser
一般的 Windows 桌面应用,首先以 CurrentUser 为基本考虑。
适合的例子:
- WPF / WinForms / WinUI 的面向用户的桌面应用
- 每位用户各自持有配置或凭据的应用
- 把配置放在
%LocalAppData%或%AppData%下的应用
这种情况下,把它当作“那个 Windows 用户的敏感信息”来处理会更自然。
6.2. LocalMachine 的适用范围相当窄
LocalMachine 看着方便,但对普通的桌面应用来说范围太宽。
适合它的,比如下面这些场景。
- 可信的单一用途机器上的 Windows 服务
- 只在那台机器的特定进程中使用的敏感信息
- 必须跨登录用户在同一台终端上使用的场景
不过,需要注意的地方分量很重。
- 那台电脑上运行的进程都能广泛解密
- 在共用终端、RDS、跳板机、有多个用户登录的环境中容易变得危险
- 以“反正大家都能用,省事”为由选它,后面多半会遇到麻烦
而想选 LocalMachine 的动机,通常就是下面这 3 个。
- 切换用户之后还能读到
- 服务也能读到
- 能跑起来就方便
这些都属于“省事”,而不是“守住了”。在普通的桌面应用里选 LocalMachine,等于把解密的可能性扩大到那台电脑上的其他进程,含义就相当不同了。
flowchart TB
accTitle: 因为省事而选 LocalMachine 会怎样
accDescr: 因为跨用户能读、服务也能读这些省事的理由而选 LocalMachine,解密的可能性就会扩大到那台电脑上的其他进程,这与守住了是两回事,本图展示这一点。
ease1["跨用户、支持服务,省事"] --> pick4["选择 LocalMachine"]
pick4 --> wide1["解密可能性扩大到电脑上的其他进程"]
wide1 -.-> not1["这是省事,不是守住了"]
图9:因为省事而选 LocalMachine,能解密的范围就扩大到了整台电脑。
6.3. 犹豫时可以这样想
- 一般的 UI 应用 ->
CurrentUser - 真的需要以机器为单位守护的特殊场景 ->
LocalMachine - 需要任何用户都能解密,但终端上还有其他用户 -> 通常最好从设计上重新审视
6.4. 涉及服务或模拟(impersonation)时,注意事项会变多
一旦把 Windows 服务或模拟(impersonation)掺进来,CurrentUser 的含义就变重了。
- 运行账户是谁
- 该账户的用户配置文件是否已加载
- 解密发生在哪个上下文中
这些地方一旦对不上,就很容易变成“加密成功了却解密不了”。服务场景不一定能用“先用 CurrentUser 再说”糊弄过去。
在模拟的场景下,Microsoft Learn 里也明确写出的典型失败是“Key not valid for use in specified state.”。DPAPI 把密钥数据保存在用户配置文件中,因此配置文件没有加载就无法解密。在执行模拟之前,需要先加载目标用户的配置文件。
flowchart TB
accTitle: 模拟场景下的典型失败
accDescr: DPAPI 把密钥数据保存在用户配置文件中,因此在配置文件未加载的状态下执行模拟再解密就会报出典型错误,需要先加载目标用户的配置文件,本图展示这一点。
imp1["未加载就执行模拟"] --> ferr1["解密报错失败"]
ld1["先加载用户配置文件"] --> okp1["模拟之后也能解密"]
imp1 -.-> whyp1["密钥就在用户配置文件里"]
图10:密钥在用户配置文件一侧,所以模拟之前需要先加载配置文件。
6.5. 先了解运维中“解密不了”的几种情况
比起加密本身,解密不了的问题在实务中更让人头疼。DPAPI 是把能够解密的主体绑定到 Windows 用户 / 计算机的机制,所以这层绑定一断,数据就读不出来了。
需要先了解的是下面 5 种情况。
| 情况 | 会发生什么 | 如何准备 |
|---|---|---|
| 管理员重置密码 | 与用户密码关联的保护会失效,有可能无法再访问用 DPAPI 保护的数据。Microsoft 的支持文档中也记载了管理员重置密码后无法访问 DPAPI 数据的现象 | 把敏感信息设计成“可以重新获取”的。解密失败时引导用户重新输入 |
| 重建用户配置文件 | 新的配置文件持有不同的密钥材料,因此无法解密以前的密文 | 给配置文件加上版本,不要让解密失败变成异常退出 |
| 只把密文复制到别的电脑 | 解密所需的密钥材料在用户配置文件一侧,所以只带走 CurrentUser 的密文是读不出来的(这正是 3.1. 中列出的“强项”的另一面) |
按每台终端重新保存一次的前提来设计 |
| 漫游用户配置文件 | 这种情况读得出来。密钥材料会随配置文件一起移动,Microsoft Learn 也明确写道,拥有漫游用户配置文件的用户可以从网络上的另一台计算机解密。如果把它与上一行同等对待,迁移步骤里就会白白重建一遍凭据 | 不要一口咬定“换了电脑就读不出来”。先确认是否使用漫游配置文件,再决定迁移步骤 |
| 服务的运行账户变更 | 如果保护时和解密时的运行账户不同,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
- 用户名
- 数据库名
- 功能开关
这些在很多情况下保持明文也没问题。
另一方面,
- 密码
- API 令牌
- 刷新令牌
- 共享文件夹的凭据
这些属于保护对象。
按这样区分之后,就能得到下面这些好处。
- 配置容易编辑
- 差异容易比对
- 哪里是敏感信息一目了然
- 整体运维更简单
这些好处会同时成立。
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
还有一点,比动手实现更该先知道。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. 想在多台机器、多位用户之间使用同一份敏感信息
想让同一份密文在多台电脑上都能解开、想让多位用户共用,这类需求已经超出了“绑定到那台终端、那位用户”的 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 都自己决定) | 操作系统(不必自己设计保存位置) | 操作系统(可以从控制面板的凭据管理器进行管理) |
取舍的大致标准如下。
- 要保存的是用户名 + 密码的组合,而且条数也不多 -> Credential Locker / Credential Manager 是首选。保存位置的设计和 ACL 都不用自己扛
- 要保存的东西不是“用户名 + 密码”这种形态 -> 连接字符串、API 令牌、刷新令牌、配置文件的一部分这类东西,用 DPAPI 更顺手。本文讨论的就是这一侧
- 想在设备之间继承 -> Credential Locker 的漫游能派上用场。DPAPI 的
CurrentUser恰恰以“不会被继承”为优点,这里的目的正好相反 - 条数多 / 体积大 -> 会撞上 Credential Locker 的 20 条限制。这时改用 DPAPI 写进自己的文件
另外,凭据存储的内部同样跑在操作系统的保护机制之上,所以这并不是“比 DPAPI 更安全”“DPAPI 更差”这种排序。按要保存的东西的形态、以及是否需要漫游来选才更贴近实务。
而且不论选哪一个,如果是新应用,都值得先考虑 Windows Hello / 通行密钥这类无密码方案。如果能做到根本不持有长期密码,那才是最强的。
本文的核心,始终是为了“让 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 -->|"一个就够(用户本人,<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:向按用户区分的敏感信息靠拢
- 与其用共用敏感信息,不如按用户区分
- 与其用长期固定凭据,不如用可更新的令牌
- 避免所有客户端共用同一把密钥
优先级 3:确实需要本地保存就用 DPAPI
- 通常用
CurrentUser - 存放位置选 per-user
- 只保护敏感项
- 不要写进日志
优先级 4:LocalMachine 按例外处理
- 是不是真的必须以机器为单位
- 那台终端会不会有其他用户登录
- 作为服务设计是否合理
12. 总结
在 Windows 应用中必须把敏感信息保存到配置文件时,应尽量避免以明文放置。
而对于
“密钥反正总要存在某个地方,那不都一样吗?”
这个疑问,比较贴近实务的回答是这样。
- 如果是自制加密、把密钥放在同一个地方,那确实相当于一样
- DPAPI 并不一样
- 能把密钥管理交给操作系统
- 能把解密主体绑定到 Windows 用户 / 计算机
- 能避免文件单独外泄直接变成敏感信息外泄
- 但是
- 以相同用户权限运行的代码
- 被完全入侵的终端
- 本来就不该放在客户端的长期共用敏感信息
这些它解决不了。
总之,DPAPI 不是万能的城墙。但它至少有把“配置文件明文”这块完全透光的玻璃,换成一扇起码像样的窗这种程度的效果。
在 Windows 客户端的实务中,这个差别相当大。先从守住这一点开始,才是最现实的做法。
flowchart TB
accTitle: DPAPI 的现实定位
accDescr: DPAPI 不是万能的城墙,但能把配置文件明文这块完全透光的玻璃换成一扇起码像样的窗,先从这里开始才现实,本图展示这一定位。
glass1["明文配置 = 完全透光的玻璃"] -->|"换成 DPAPI"| win2["起码像样的窗"]
win2 -.-> notwall1["不是万能的城墙"]
win2 --> first1["先从这里开始才现实"]
图18:DPAPI 不是城墙而是换窗户,但在实务中这个差别最管用。
13. 参考资料
- 上一篇文章: /zh-CN/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为何演变成如今的样子:从开发者视角看历代Windows的演进
本文从兼容性、稳定性、权限管理、驱动程序、Win32、.NET、信息安全等Windows应用程序开发者的视角,梳理Windows 95到Windows 11的变化,而不是单纯以外观年表的方式呈现。
意外异常发生时,应用该终止还是继续的判断表
从状态破坏、外部副作用、线程、原生边界这几个角度,梳理发生意料之外的异常时,应该让应用终止还是继续运行。
快速启动的真面目 ── Windows 的「关机」为什么和重启不一样
Windows 的「关机」默认会变成混合关机,内核与驱动程序被保存到休眠文件,并在下次启动时还原。本文讲解为什么有些问题只有重启才能解决、对运行时间・更新・Wake on LAN 的影响、确认方法以及是否停用的判断。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
凭据的保存方式、按用户区分的配置存放位置、日志的输出方式都会牵动 Windows 应用的整体设计,因此这个主题与 Windows 应用开发很契合。
技术咨询 & 设计评审
如果想先从梳理现有应用的明文配置、区分 DPAPI 与 Credential Locker 的适用场景入手,这个主题很适合作为技术咨询与设计评审来推进。
常见问题
汇总了咨询这一主题时常见的问题。
- DPAPI 是什么?
- DPAPI(Data Protection API)是 Windows 提供的数据保护机制,它把密钥管理交给操作系统,并把能够解密的主体绑定到某个 Windows 用户或某台计算机上。在 C# / .NET 中可以通过 System.Security.Cryptography.ProtectedData 类使用,不需要额外引入其他库。与其把它看成一个用来挑选加密算法的 API,不如把它看成一个把密钥管理委托给操作系统的 API,这更接近本质。对于要存进配置文件的密码和 API 令牌,它是避免明文存放的现实选择。
- 密钥反正总要存在某个地方,那明文和 DPAPI 不是一样吗?
- 并不一样。如果自己写 AES 加密,又把密钥放在同一个应用或同一个配置文件里,那确实相当接近明文;但 DPAPI 把密钥管理交给操作系统,并把能够解密的主体绑定到 Windows 用户或计算机上。结果是,面对配置文件单独外泄、被带到别的电脑、误发邮件、备份外泄、混进代码仓库这类事故时,防护强度差别很大。DPAPI 与明文的决定性区别在于,它能把“能读到文件”和“能用到敏感信息”这两件事分开。
- DPAPI 防不住什么?
- 以相同用户权限运行的代码,原则上能解密该用户可以解密的内容。因此,终端已被恶意软件入侵的情况、被以管理员权限接管的情况,以及解密之后留在内存里的明文,DPAPI 都防不住。另外,向所有客户端统一下发的长期敏感信息,只要从任何一台设备上被取走就容易波及全体,即便用 DPAPI 保存也不是根本解决办法。DPAPI 主要作用在文件外泄、放错位置、离线带走、被其他用户读取这一侧。
- DataProtectionScope 的 CurrentUser 和 LocalMachine 该用哪个?
- 一般的 Windows 桌面应用以 CurrentUser 为基本选择。这样可以把它当作那个用户的敏感信息来处理,并且只把密文复制到别的电脑也难以直接使用。LocalMachine 允许那台电脑上运行的进程广泛解密,在共用终端或有多个用户登录的环境中很容易变得危险,适用范围相当窄,基本只限于可信的单一用途机器上的 Windows 服务之类。如果只是因为“大家都能用比较省事”而选 LocalMachine,后面多半会遇到麻烦。