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 用户」或「那台电脑」上
  • 结果,对于 配置文件单独外泄、被带到别的电脑、误发邮件、备份外泄、不小心混进代码仓库 这类事故的防御力会有很大差异

换言之,
只看「密钥放在某处」这个抽象说法的话是一样,但
「谁,在什么情境下,能多简单地用得到」这件事完全不同

把「把钥匙放在门口地垫下」和「到管理室本人认证之后才能拿到钥匙」当成同一件事,有点粗暴。

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

明文保存危险的理由,与其说是密码学层面的问题,更多是很「土」的现实。实务上大致是通过以下几种途径漏出去的。

  • 配置文件就这样被提交进 Git
  • 故障排查用的 ZIP 里整个配置文件被打包进去
  • 反馈问题时被要求附上配置文件
  • 备份或文件分享让第三者读到
  • 日志里直接打印出连接字符串或 Token
  • 离职员工或其他用户能读到同一台电脑上的文件

明文最大的问题是 「被读到的那一瞬间,敏感信息就已经结束了」

  • 文件被打开就结束
  • 被复制就结束
  • 被附在邮件里就结束
  • 一旦留在代码仓库里,就要半永久地背着它

攻击者根本不需要很高深的技术。
「用文本编辑器就能打开」这件事本身就已经相当弱。

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

这个疑问很合理。
而且如果这里回答得太草率,整篇安全文章会立刻变得模糊不清。

先说结论:以「某处一定要有密钥」这层意思来说是 yes,但要说「所以都一样」就是 no。

3.1. 哪里一样、哪里不一样

的确,加密最终总得有某个 root of trust。
敏感信息不会从宇宙某处凭空冒出来。这一点没有商量的余地。

但安全性上的差异,是由下列 3 点决定的。

  • 密钥是不是应用自己在持有
  • 密钥绑定在哪个主体上
  • 仅凭文件被拿走,是否就能解开

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

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

这里重要的是,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

  • 以该用户已登录
  • 以该用户上下文在运行

为前提解密。

这使得 只把密文复制到别的电脑也很难直接使用 这种特性变得可实现。

4.3. 比较容易顺带具备篡改检测

自制加密常见的情况是,
「反正我做了 AES 加密就结束了」然后 忘了加上篡改检测

DPAPI 对加密数据本身就具备完整性保护,所以
偷偷篡改密文的检测 也顺带交给 OS 的机制,实务上是一个好处。

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

C# 直接用 System.Security.Cryptography.ProtectedData 就可以。
不用再额外挂载别的库,对 Windows 专用程序来说帮助不小。

5. DPAPI 能守住、守不住的东西

这里最好明确区分,比较安全。

5.1. 变得比较能守住的

DPAPI 至少在下列场景是有效的。

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

5.2. 守不住或很弱的

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

  • 以相同用户权限运行的攻击代码
  • 终端本身完全沦陷
  • 被管理员权限接管
  • 应用解密后,内存里的明文
  • 分发给所有客户端、共用的长期敏感信息

最后那个「所有客户端共用的长期敏感信息」特别重要。

例如:

  • 所有客户端内嵌同一个 API 密钥
  • 所有终端用同一个共用密码
  • 只靠客户端就能完成的固定解密密钥

这类设计 只要有一台被攻破,就很容易波及整体

DPAPI 对于「把保存方式做得比明文好」是有效的,
它无法替「本来就不该放在客户端的敏感信息」背书。

6. CurrentUserLocalMachine 的使用区分

这里相当重要。随便选会让含义完全不同。

6.1. 基本用 CurrentUser

一般 Windows 桌面应用,首先以 CurrentUser 为基本选择。

适合的例子:

  • WPF / WinForms / WinUI 的面向用户的桌面应用
  • 每位用户各自持有配置或凭证的应用
  • 把配置放在 %LocalAppData%%AppData% 下的应用

这种情况下,会变成 「那个 Windows 用户的敏感信息」,处理起来比较自然。

6.2. LocalMachine 的用途相当有限

LocalMachine 看起来很方便,但对一般桌面应用来说范围太广。

适合的场景,例如:

  • 可信的单一用途机器上的 Windows 服务
  • 只会在那台机器的特定进程使用的敏感信息
  • 必须跨登录用户在同一台机器上共用的场景

不过注意事项相当沉重。

  • 那台电脑上运行的各种进程都能广泛解密
  • 共用终端、RDS、跳板机、多用户共用环境容易变得危险
  • 以「反正大家都能用很方便」为理由选它,通常后面都会后悔

6.3. 犹豫时这样想

  • 一般 UI 应用 -> CurrentUser
  • 真的需要以机器为单位守住的特殊情况 -> LocalMachine
  • 任何用户都要能解密、但终端上还有其他用户在 -> 通常最好从设计上重新审视一次

6.4. 涉及服务或 impersonation 时,注意事项会变多

把 Windows 服务或 impersonation 掺在一起考虑时,CurrentUser 的含义会变重。

  • 运行账号是谁
  • 该账号的 Profile 是否已被加载
  • 解密时机处于哪个上下文

只要这些对不上,就很容易出现 「能加密但解不开」 的情况。
服务场景通常不能靠「反正先用 CurrentUser」就解决。

7. 实现的最底限方针

在 Windows 应用中,光是「不要再用明文配置」,设计其实不需要搞得太复杂。
不过有几个不想漏掉的要点。

7.1. 只保护敏感信息本身

与其把整个配置全部加密,不如先只保护 敏感字段,比较好处理。

例如,像下面这样区分。

  • Server URL
  • 用户名
  • 数据库名称
  • 功能开关

这些通常可以维持明文。

另一方面,

  • 密码
  • 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

但对一般桌面应用来说,这个选择会
「把解密可能性扩大到那台电脑上的其他进程」,
意义完全不同。

9.4. 再叠一层自制加密就以为安心

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

  • 把 AES 密钥写死在源代码里
  • 把 AES 密钥写在配置文件的另一个字段里
  • 把「稍微混淆过的字符串」当密钥用

这些做法大多效果有限。

「不是明文」和「安全」之间还有一条相当大的鸿沟。

10. DPAPI 不够用的场景

DPAPI 方便,但不是万能的。下列场景应该考虑其他方案。

10.1. 想在 Windows 以外也能跑

DPAPI / ProtectedData 是 Windows 专属的。
跨平台应用无法以此为前提来构建。

10.2. 想在多台机器、多位用户间处理同一份敏感信息

想让同一份密文在多台电脑都能解开、想让多位用户共用,
都超出了「把敏感信息绑定在那台终端、那位用户身上」这个 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

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

伪随机数与真随机数的区别 - 如何区分的整理

本文整理伪随机数与真随机数的区别,重点不在输出外观而在生成器结构:普通 PRNG 重视可重现性、CSPRNG / DRBG 主打不可预测性、NRBG 则以物理熵源为基础。文中说明种子(seed)与重新播种(reseed)、健康检测(health test)的作用,并给出信息...

常见问题

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

Windows 应用的密码或 API Token 该怎么保存?
按顺序思考:首先从根本上不要让客户端持有长期敏感信息,优先采用 Windows 身份验证、集成身份验证、用户交互式登录或服务端密钥管理;如果确实必须存在本地,就不要以明文存放,在 Windows 上首先考虑 DPAPI / ProtectedData;一般桌面应用基本用 DataProtectionScope.CurrentUser,LocalMachine 的适用范围相当窄。
反正密钥总得存在某个地方,DPAPI 和明文不是一样吗?
这句话一半对,但结论错了。自己写 AES 加密、把密钥放在同一个应用或配置文件里,确实相当接近明文;但 DPAPI 是把密钥管理交给 OS,并把能解密的主体绑定到「那个 Windows 用户」或「那台电脑」上。结果对配置文件单独外泄、被带到别的电脑、误发邮件、备份外泄、混进代码仓库这类事故的防御力有很大差异。DPAPI 把「能读文件」和「能用敏感信息」这两件事分开了。
DPAPI 能防住哪些情况、防不住哪些情况?
能发挥作用的是配置文件明文外泄、文件被带到别的电脑、同一台电脑上其他用户尝试读取(以 CurrentUser 为前提)、以备份或附件形式外泄等场景。防不住的是以相同用户权限运行的攻击代码、终端本身完全沦陷、管理员权限被接管、解密后内存中的明文,以及所有客户端共用的长期敏感信息——这类信息本来就不该放在客户端。
DataProtectionScope 的 CurrentUser 和 LocalMachine 该怎么选?
一般 UI 桌面应用以 CurrentUser 为基本选择,会变成「那个 Windows 用户的敏感信息」,只把密文复制到别的电脑也难以直接使用。LocalMachine 的用途相当有限,适合可信的单一用途机器上的 Windows 服务等场景,但那台电脑上运行的各种进程都能广泛解密,共用终端或 RDS 环境容易变得危险。如果任何用户都要能解密、而且终端上还有其他用户,通常最好重新审视设计。

作者简介

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

Go Komura

小村软件有限公司 代表

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

返回博客列表