Windows 应用的敏感信息存储 - 用 DPAPI 摆脱明文配置

· 更新日期: · · Windows 开发, 信息安全, DPAPI, C# / .NET, Win32

更新记录(2 条,最后更新 2026年09月03日)

本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。

本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.22276799)
补充了日文原文中已有的咨询引导(consultation_services)。正文内容没有改动。 查看更新前的版本 (DOI: 10.5281/zenodo.21615469)
首次发布
引用本文(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. 先说结论

在实务中,按下面这个顺序思考会比较清楚。

  1. 从根本上不让客户端持有长期敏感信息
    • 优先采用 Windows 身份验证、集成身份验证、用户交互式登录、服务端的密钥管理
  2. 如果确实必须存在本地,就不要以明文存放
    • 在 Windows 上先把 DPAPI / ProtectedData 作为首选
  3. 一般的桌面应用以 DataProtectionScope.CurrentUser 为基本选择
    • LocalMachine 的适用范围相当窄
  4. DPAPI 并不负责守住“终端已被完全攻陷”的局面
    • 以相同用户权限运行的代码,原则上能解密该用户可以解密的内容

而本文最重要的论点在这里。

“密钥反正总要存在某个地方,那么无论明文还是 DPAPI,在安全上不都一样吗?”

这句话对了一半,但结论是错的。

  • 如果是自己写 AES 加密,再把密钥放在同一个应用或同一份配置里,那确实相当接近明文
  • 但 DPAPI 把密钥管理交给操作系统,并把能够解密的主体绑定到“那个 Windows 用户”或“那台计算机”上
  • 结果是,面对配置文件单独外泄、被带到别的电脑、误发邮件、备份外泄、混进代码仓库这类事故时,防护强度差别很大。

也就是说,只看“密钥总在某个地方”这个抽象说法时两者看起来一样,但“谁、在什么情境下、能多轻松地用到它”完全不同。

把钥匙放在门口地垫下面,和到管理室核验身份之后才领到钥匙,硬说成一回事,未免有点粗糙。

只说“密钥总在某个地方”并不能得出两者相同从密钥总在某个地方这一抽象说法看,明文和 DPAPI 似乎一样,但谁、在什么情境下、能多轻松地用到它完全不同,本图展示的正是本文的核心论点。只看这里看这里密钥总在某个地方(抽象说法)看起来一样谁、在什么情境下、能多轻松地用到它完全不同

图1:抽象说法上相同,但按“谁在什么情境下能用到”来看,明文与 DPAPI 就分开了。

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 21 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

2. 为什么明文配置很危险

明文保存之所以危险,原因远比密码学理论更接地气。实务中,敏感信息大致是从下面这些路径漏出去的。

  • 配置文件就这样被提交进 Git
  • 故障排查用的 ZIP 里原样打包了配置文件
  • 让用户在提交支持请求时附上配置文件
  • 备份或文件共享让第三方能读到
  • 日志里直接打印出连接字符串或令牌
  • 离职员工或其他用户能读到同一台终端上的文件

明文只要被第三方读到,保密性就失效了。

  • 文件被打开就完了
  • 能被复制就完了
  • 被作为邮件附件发出去就完了
  • 一旦留在代码仓库里,就得半永久地为它收拾麻烦

攻击者甚至不需要多高的水平。用文本编辑器就能打开,这一点本身就已经相当脆弱。

明文被读到的那一刻保密性就失效配置文件通过混进 Git、排查用 ZIP、备份外泄、邮件附件这些接地气的路径被第三方读到时,明文就会失去保密性,本图展示这一过程。混进 Git、排查用 ZIP、邮件附件、备份配置文件被读到被第三方读到的那一刻保密性就失效攻击者甚至不需要多高的水平

图2:明文的脆弱之处在于,只要日常的事故路径让第三方读到,保密性当场就没了。

3. 对“密钥反正总要存在某个地方,那不是一样吗?”的回答

这个疑问很合理。而如果在这里回答得太潦草,整篇安全文章就会一下子变得含混。

回答是:就“某个地方总要有密钥”这层意思而言是 yes,但要说“所以两者一样”就是 no。

3.1. 哪里相同,哪里不同

的确,加密最终需要某种 root of trust。也就是说,加密最终需要某个信任起点。

不过,安全上的差异由下面 3 点决定。

  • 密钥是不是由应用直接持有
  • 密钥绑定在哪个主体上
  • 只有文件被偷走时能不能解密

把这些差异粗略整理成表,大致如下。

方式 配置文件被读到 只有文件被带到别的电脑 被同一台电脑的其他用户读到 以相同用户权限运行的代码
明文 当场外泄 直接外泄 直接外泄 当然读得到
自制加密 + 密钥放在同一份配置 / 同一个可执行文件里 相当容易泄露 相当容易泄露 相当容易泄露 当然解得开
DPAPI + CurrentUser 仅凭文件无法立即读取 通常难以解密 通常难以解密 可以解密
DPAPI + LocalMachine 仅凭文件无法立即读取 在那台电脑以外通常难以解密 在同一台电脑上可以广泛解密 可以解密

这里的关键是,DPAPI 把“能读到文件”和“能用到敏感信息”分开了。

解密所需的东西 ── 留在操作系统一侧,不包含在密文里用 CurrentUser 保护用 LocalMachine 保护用户的主密钥用 CurrentUser 保护时计算机的主密钥用 LocalMachine 保护时密文会进入配置文件 / 数据库的列 / 排查用 ZIP= 有可能被带走的东西以该用户身份运行的代码→ 可以解密同一台电脑的其他用户→ 无法解密同一台电脑上的代码→ 换个用户也能解密只把密文复制到别的电脑→ 无法解密连同漫游用户配置文件一起迁移→ 密钥材料也一起移动,所以可以解密(6.5 节)

图3:可以被带走的只有密文,解密所需的主密钥留在操作系统一侧。不过 LocalMachine 的作用范围是整台电脑,挡不住同一台电脑上的其他用户

在明文下,这两件事是一回事。文件能读到,敏感信息也就读到了。

但在 DPAPI 下,至少 CurrentUser 的场景需要满足:

  • 以该 Windows 用户的身份
  • 在该 Windows 的上下文中
  • 通过操作系统的保护机制

才能解密。

这个差别,在事故现场相当关键。

3.2. “可是同一个用户不还是能解密吗?”——确实如此

这一点应该老老实实写出来,不该含糊。

以相同用户权限运行的代码,原则上能解密该用户可以解密的内容。

也就是说,DPAPI 并不以下面这些局面为主要目标。

  • 终端已经被恶意软件入侵
  • 攻击者能以该用户身份运行代码
  • 已被以终端管理员级别完全接管

在这种局面下,既然应用自己能解密,攻击者的代码自然也解得开。此时说一句“可是我们加密了”,并不怎么让人安心。

DPAPI 起作用的地方,主要是“文件外泄、放错位置、离线带走、被其他用户读取”这一侧。

一旦在这里搞错,就会同时出现:

  • 低估它能守住的东西,于是干脆不用
  • 高估它守不住的东西,于是放心过头

两种情况。哪一种都在不知不觉中很危险。

DPAPI 起作用的一侧与不起作用的一侧DPAPI 起作用的是文件外泄、放错位置、离线带走、被其他用户读取这一侧,对以相同用户权限运行的攻击代码和已被入侵的终端不起作用,本图展示这条界线。起作用的一侧不起作用的一侧DPAPI 的防守范围外泄、放错位置、其他用户相同用户权限下的代码搞错这条线就会误判

图4:不知道起作用与不起作用的界线,就会要么低估到不用,要么高估到过度放心。

3.3. 那么好处到底在哪

DPAPI 的好处用一句话概括就是:

“能把敏感信息本身从配置文件的可读性中剥离出来。”

比如下面这些事故,明文和 DPAPI 之间就会拉开差距。

  • 用户把配置文件发给了支持人员
  • 排查用的 ZIP 里装进了配置文件
  • 备份中只有配置文件外泄
  • 被复制到了共享文件夹上
  • 开发者只看得到密文,读不出里面的内容

这是相当现实的好处。不用把攻击者想象成电影里的超人,也能把日常事故的半径缩小。

事故半径变小的原理即使发生误发给支持人员、排查用 ZIP、备份外泄这类日常事故导致文件流出,流出去的也只是密文,不会直接变成敏感信息外泄,事故半径因此变小,本图展示这一原理。误发、排查 ZIP、备份外泄流出去的只有密文不至于直接变成敏感信息外泄日常的事故半径变小

图5:文件流出的事故防不住,但可以把流出去的东西换成密文。

4. DPAPI 恰到好处的理由

在 Windows 上处理需要本地保存的敏感信息时,DPAPI 在实务上恰到好处,理由如下。

4.1. 可以把密钥管理交给操作系统

自己生成 AES 密钥、保存它、给它配好权限、做轮换、评估泄露时的影响,还要加上篡改检测。这比想象中更重。而且做得潦草的话,多半就是把密钥放在同一个地方了事。

用 DPAPI,就能把“加密密钥怎么生成、放在哪里”这个问题从应用实现中剥离出去。

从这个意义上说,与其把 DPAPI 看成“挑选加密算法的 API”,不如看成“把密钥管理委托给操作系统的 API”,这更接近本质。

看待 DPAPI 的本质视角DPAPI 不是挑选加密算法的 API,而是把密钥怎么生成、放在哪里这个密钥管理问题委托给操作系统的 API,本图展示这个更接近本质的视角。不是这个视角这个视角更接近本质挑选算法的 APIDPAPI把密钥管理委托给操作系统的 API密钥的生成与保管问题可以从实现中剥离

图6:把 DPAPI 看成密钥管理的托管方,就能看清应用可以剥离掉什么。

4.2. 能把解密主体绑定到 Windows 用户或计算机

一般的桌面应用,多数场景选 CurrentUser 就可以。

  • 该用户已经登录
  • 处理在该用户的上下文中运行

解密以上面这两点为前提。

因此,就能得到只把密文复制到别的电脑也难以直接使用这种性质。

4.3. 容易把篡改检测一并纳入

自制加密常见的毛病是,认为“用 AES 加密完就结束了”,于是忘掉篡改检测。

DPAPI 对加密数据本身带有完整性保护,因此密文被擅自改写时的检测也容易一并交给操作系统的机制,这是实务上的一个好处。

篡改检测上的差别自制加密容易认为用 AES 加密完就结束而忘掉篡改检测,而 DPAPI 对加密数据带有完整性保护,密文被改写的检测也能交给操作系统的机制,本图展示这一差别。自制加密容易忘掉篡改检测DPAPI带有完整性保护连改写的检测也交给操作系统一侧

图7:不只是加密,连篡改检测都能交给操作系统的机制,这是 DPAPI 的优点。

4.4. 从 C# / .NET 可以直接使用

用 C# 就能直接使用 System.Security.Cryptography.ProtectedData。不用额外引入其他库,这一点对 Windows 专用应用来说帮助不小。

5. DPAPI 守得住什么,守不住什么

这里最好明确区分开,这样更安全。

5.1. 变得容易守住的

DPAPI 至少在下面这些场景是有效的。

  • 配置文件的明文泄露
  • 把文件带到别的电脑
  • 同一台电脑上其他用户的读取(以 CurrentUser 为前提)
  • 以备份或邮件附件形式外泄
  • 开发与运维现场“一不小心就被读到”的状态

5.2. 守不住或者防护很弱的

另一方面,在下面这些局面下最好不要过度信任它。

  • 以相同用户权限运行的攻击代码
  • 终端本身被完全入侵
  • 被以管理员权限接管
  • 应用解密之后留在内存里的明文
  • 向所有客户端统一下发的长期敏感信息

最后一条“所有客户端共用的长期敏感信息”尤其重要。

比如:

  • 给所有客户内嵌同一个 API 密钥
  • 所有终端使用同一个共享密码
  • 下发只靠客户端就能闭环的固定解密密钥

这类设计只要从任何一台设备上被取走,就很容易波及全体。道理很简单,只要有任何一台设备上的应用能解密,那份敏感信息就能被取出来。

DPAPI 对于“把保存方式做得比明文强”是有效的,但它并不能为本来就不该放在客户端的敏感信息背书。

共用长期敏感信息的波及原理向所有客户端下发共用的长期敏感信息后,只要任何一台设备上的应用能解密,那份信息就能被取出,因此一台被攻破就容易波及全体,本图展示这一原理。所有客户端共用的长期敏感信息某一台设备上的应用能解密就能从那一台设备取出敏感信息波及全体用 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,等于把解密的可能性扩大到那台电脑上的其他进程,含义就相当不同了。

因为省事而选 LocalMachine 会怎样因为跨用户能读、服务也能读这些省事的理由而选 LocalMachine,解密的可能性就会扩大到那台电脑上的其他进程,这与守住了是两回事,本图展示这一点。跨用户、支持服务,省事选择 LocalMachine解密可能性扩大到电脑上的其他进程这是省事,不是守住了

图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 把密钥数据保存在用户配置文件中,因此配置文件没有加载就无法解密。在执行模拟之前,需要先加载目标用户的配置文件。

模拟场景下的典型失败DPAPI 把密钥数据保存在用户配置文件中,因此在配置文件未加载的状态下执行模拟再解密就会报出典型错误,需要先加载目标用户的配置文件,本图展示这一点。未加载就执行模拟解密报错失败先加载用户配置文件模拟之后也能解密密钥就在用户配置文件里

图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;
    }
}

因为“加密了却解不开”而找上门来的咨询,基本都落在上面这张表里。

以解密可能失败为前提的代码流程以 ProtectedData.Unprotect 可能因环境变化而失败为前提来写代码,接住 CryptographicException 不让程序异常退出,并引导用户重新输入,本图展示这一流程。成功抛异常失败尝试执行 Unprotect使用敏感信息接住异常,不让程序崩掉引导用户重新输入绑定一断就可能失败

图11:以解密可能失败为前提来写,把失败接到重新输入而不是异常退出。

7. 实现的最低限度方针

在 Windows 应用里,如果只是要“不再用明文配置文件”,设计不必搞得那么复杂。不过,有几个点是不想漏掉的。

7.1. 只保护敏感项

与其把整份配置全部加密,不如先只保护敏感项,这样更好处理。

比如,按下面这样区分。

  • 服务器 URL
  • 用户名
  • 数据库名
  • 功能开关

这些在很多情况下保持明文也没问题。

另一方面,

  • 密码
  • API 令牌
  • 刷新令牌
  • 共享文件夹的凭据

这些属于保护对象。

按这样区分之后,就能得到下面这些好处。

  • 配置容易编辑
  • 差异容易比对
  • 哪里是敏感信息一目了然
  • 整体运维更简单

这些好处会同时成立。

只保护敏感项的划分方式不把整份配置全部加密,而是让 URL、用户名等保持明文,只保护密码和令牌等敏感项,这样既容易编辑,哪里是敏感信息也一目了然,本图展示这种划分方式。保持明文进行保护配置文件URL、用户名、开关等密码、令牌等哪里是敏感信息很明确,运维也简单

图12:不做整体加密,只保护敏感项,就能兼顾易用性和安全性。

7.2. 存放位置以 per-user 为基本

一般的桌面应用,存放位置基本选在 per-user 的位置。

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

至少最好不要随手放在安装目录下或者容易被共享的位置。

即便用 DPAPI 做了保护,如果存放位置的 ACL 很随便,就会变成“密文被读走”“配置结构被看光”“运维失误照样发生”这种局面。防御不该只有一层,叠起来才更有效。

7.3. optionalEntropy 不是万能的第二把密钥

ProtectedData 可以传入 optionalEntropy。它很方便,但并不是“把它嵌进可执行文件就会变安全的第二把魔法密钥”。

  • 放在同一个文件里就算不上秘密
  • 用固定值嵌进可执行文件,也称不上是强敏感信息
  • 即便如此,它对用途识别和防止误用仍然有帮助

在实务中,把

  • 应用名
  • 用途名
  • 版本标识

作为固定的字节序列传进去,用来“不会误接受其他用途的密文”,这个程度刚刚好。

optionalEntropy 的正确定位optionalEntropy 不是嵌进可执行文件就会变安全的第二把魔法密钥,把应用名和用途名作为固定字节序列传入、用于不误接受其他用途的密文这种用途识别才刚刚好,本图展示这一定位。不要抱这种期待这种用法刚刚好optionalEntropy魔法般的第二把密钥应用名、用途名的标识不会误接受其他用途的密文

图13:entropy 不是密钥,而是用于用途识别和防止误用的标签。

7.4. 这不等于“密文可以进 Git”

这一点低调但重要。

DPAPI 的密文比明文强得多,但这并不等于可以把配置文件整个提交进代码仓库。

理由很简单:

  • 密文会留存很久
  • 说不定哪天同一台终端、同一个上下文会被重现
  • 文件里还装着敏感信息以外的信息
  • 会养出“反正有保护,随便处理也行”的文化

“比明文强”和“放哪儿都安全”完全是两回事。

即使是密文也不放进 Git 的理由DPAPI 的密文虽然比明文强,但放进代码仓库会长期留存、还包含敏感信息以外的信息,并会养出有保护就可以随便处理的文化,所以这与放哪儿都安全是两回事,本图展示这些理由。比明文强即便如此DPAPI 的密文更能扛住事故也不要放进代码仓库长期留存、还含其他信息、文化会松弛

图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. 所说,从一开始就采用别的设计。

使用 ProtectedData 之前的确认事项ProtectedData 需要按目标框架添加相应引用,不添加就无法解析类型名,而且它是 Windows 专用的,在 Windows 以外调用会抛异常,本图展示实现前该确认的这两点。不添加的话在 Windows 以外调用想使用 ProtectedData按目标框架添加引用类型名无法解析先理解它是 Windows 专用会抛出运行时异常

图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 密钥放在配置文件的另一个字段里
  • 把“稍微混淆过的字符串”当成密钥

引入这类实现,效果通常很有限。

“不是明文”和“是安全的”之间,隔着相当大的一道沟。

靠自制加密求安心的危险把 AES 密钥写死在源代码或配置文件另一个字段里、把混淆过的字符串当密钥这类自制加密效果很有限,不是明文与是安全的之间隔着很大一道沟,本图展示这一点。中间隔着大沟把密钥嵌进代码或配置的自制加密不是明文的状态安全的状态效果通常很有限

图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. 实务中推荐的优先顺序

最后,在实务中拿不定主意时,按下面这个顺序思考会比较容易理清。自上而下逐条考虑,只在条件不满足时才往下走。

能不能不能区分能区分是这个组合,条数也不多需要是的域账户 / 本地账户不需要令牌等其他形态需要不需要一个就够(用户本人,或专用的服务账户)需要从多个账户进行解密能断定不能断定能不能做到不让终端持有长期敏感信息优先级 1:不持有(Windows 身份验证、短期令牌)敏感信息能不能按用户区分检查是不是变成了共用密钥从设计上重新审视要保存的东西是不是用户名 + 密码的组合(10.3 节)是否需要在设备之间继承是不是通过 Microsoft 账户同步的设备(10.3 节)使用 Credential Locker的漫游DPAPI 无法继承。改由服务端管理(10.2 节)Credential Locker /Credential Manager是否需要在设备之间继承需要解密同一份敏感信息的账户有几个优先级 3:DPAPI + CurrentUser无人值守运行时要确认用户配置文件已加载(6.4 节)能不能断定不会有其他用户登录优先级 4:DPAPI + LocalMachine作为例外处理并留下依据其他用户也能解密。应该重新审视身份验证方式

图17:保存方式的选型顺序。先看敏感信息的形态和是否需要漫游,再进入 DPAPI。LocalMachine 不是因为“省事”才选,而是在其他选项都不成立时作为例外来选

优先级 1:从根本上不持有

  • Windows 身份验证
  • 集成身份验证
  • 交互式登录
  • 在服务端保管敏感信息
  • 短期令牌

优先级 2:向按用户区分的敏感信息靠拢

  • 与其用共用敏感信息,不如按用户区分
  • 与其用长期固定凭据,不如用可更新的令牌
  • 避免所有客户端共用同一把密钥

优先级 3:确实需要本地保存就用 DPAPI

  • 通常用 CurrentUser
  • 存放位置选 per-user
  • 只保护敏感项
  • 不要写进日志

优先级 4:LocalMachine 按例外处理

  • 是不是真的必须以机器为单位
  • 那台终端会不会有其他用户登录
  • 作为服务设计是否合理

12. 总结

在 Windows 应用中必须把敏感信息保存到配置文件时,应尽量避免以明文放置。

而对于

“密钥反正总要存在某个地方,那不都一样吗?”

这个疑问,比较贴近实务的回答是这样。

  • 如果是自制加密、把密钥放在同一个地方,那确实相当于一样
  • DPAPI 并不一样
    • 能把密钥管理交给操作系统
    • 能把解密主体绑定到 Windows 用户 / 计算机
    • 能避免文件单独外泄直接变成敏感信息外泄
  • 但是
    • 以相同用户权限运行的代码
    • 被完全入侵的终端
    • 本来就不该放在客户端的长期共用敏感信息

这些它解决不了。

总之,DPAPI 不是万能的城墙。但它至少有把“配置文件明文”这块完全透光的玻璃,换成一扇起码像样的窗这种程度的效果。

在 Windows 客户端的实务中,这个差别相当大。先从守住这一点开始,才是最现实的做法。

DPAPI 的现实定位DPAPI 不是万能的城墙,但能把配置文件明文这块完全透光的玻璃换成一扇起码像样的窗,先从这里开始才现实,本图展示这一定位。换成 DPAPI明文配置 = 完全透光的玻璃起码像样的窗不是万能的城墙先从这里开始才现实

图18:DPAPI 不是城墙而是换窗户,但在实务中这个差别最管用。

13. 参考资料

  • 上一篇文章: /zh-CN/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
  • 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

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

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,后面多半会遇到麻烦。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表