比较传统加解密API与数据保护API:
如果你一路从本系列的开篇看到现在,你对于 Wincrypt APIs 所提供的 CryptEncrypt 和CryptDecrypt 这组传统加解密API至少已经有了一定了解。这对API可以应用于广泛的加密场景,而且基本不依赖于用户的登录状态。它们还允许你在加密过程中选择不同的加密算法和模式,以满足特定的安全需求,从而可以更细致地控制加密操作。尤其值得注意的是,它们支持非对称加密,这意味着你可以使用公钥加密数据,只有私钥持有者才能解密,这在加密通信和数据传输方面非常有用。
然而,事物通常具有两面性。从另一个角度来看,CryptEncrypt 和 CryptDecrypt 的分步操作不仅增加了使用的复杂性,而且由于步骤繁多,也为潜在黑客提供了更多的攻击机会。正如你所了解的,大多数情况下,Windows下的本地应用程序需要的用户场景仅仅是将数据加密并持久保存,然后在需要时解密并使用。因此,Windows提供了 CryptProtectData 和 CryptUnprotectData 这一组更便捷、更安全的数据保护接口,以满足这种常见需求。其特点如下:
- 用户密钥保护:数据加密时使用用户的登录凭据,这意味着只有登录到系统特定帐户的用户才能解密数据。这有助于确保只有授权用户能够访问加密数据。
- 便于直接使用:不需要管理密钥,因为它们直接与用户的登录凭据相关联。这意味着加解密接口直接使用当前用户的登录凭据操作加密的数据,而无需手动管理密钥。
回顾一下,在使用 CryptEncrypt 和 CryptDecrypt 这组传统加解密API时,你需要选择密钥容器、密码学服务提供程序、加密类型等,还需要导出、导入和管理密钥。即使你尽量采用默认选项,也不能省略任何必要的步骤。而如今,加密和解密都可以一步完成,无需繁琐的密钥管理。这是因为操作系统视当前用户本身就是最可信的"凭据",而且系统能够自动处理这一切,所以程序连密钥都不需要管理了。因此,CryptProtectData 和 CryptUnprotectData 这一组API在需要将数据与用户关联的场景中,可以说是非常方便和高效的选择。
总之,CryptProtectData 和 CryptUnprotectData 更适合于需要将数据保护与用户关联的场景,而 CryptEncrypt 和 CryptDecrypt 更适合需要更灵活的加密和密钥管理场景,尤其是在非用户关联的情况下。选择哪种API取决于你的具体安全需求。
实例串讲数据保护API:
CryptProtectData API
是Windows操作系统提供的一个API函数,常用于本地应用程序,加密敏感数据以保护其机密性。
DPAPI_IMP BOOL CryptProtectData(
[in] DATA_BLOB *pDataIn,
[in, optional] LPCWSTR szDataDescr,
[in, optional] DATA_BLOB *pOptionalEntropy,
[in] PVOID pvReserved,
[in, optional] CRYPTPROTECT_PROMPTSTRUCT *pPromptStruct,
[in] DWORD dwFlags,
[out] DATA_BLOB *pDataOut
);
pDataIn
: 要加密的数据,以DATA_BLOB
结构(代码定义如下)传递,包括数据(以字节为单位)的指针和大小。其中DATA_BLOB
结构是Windows API中常用的结构之一,用于表示一块二进制数据的内存块。它通常用于在加密、解密、数据传输和其他操作中传递和管理二进制数据。
typedef struct _DATA_BLOB {
DWORD cbData;
BYTE *pbData;
} DATA_BLOB, *PDATA_BLOB;
szDataDescr
: 数据的描述性字符串,用于标识数据。通常可以为NULL。pOptionalEntropy
: 可选的额外熵,可以增加数据的安全性。也是一个指向DATA_BLOB
结构的指针,可以为NULL。
需要指出的是,ppszDataDescr
和 pOptionalEntropy
在本质上都充当了额外的数据保护参数,用于提高数据的安全性。在解密时返回的描述性字符串,需要与加密时描述性字符串进行比较,以避免将数据错误地解密为不同的数据。
pOptionalEntropy
的概念可能更难理解一些。它是一个可选的额外熵(entropy)参数,用于增加数据的安全性。额外熵是一段随机数据,与加密密钥相关联,旨在增加加密的复杂性。提供额外熵可以增加数据的抵抗力,使攻击者更难以猜测或破解加密密钥。
笔者个人理解,设计 ppszDataDescr
和 pOptionalEntropy
这两个参数的一部分目的就是为了增加对加密程序逆向研发的难度和复杂性,以提高数据的安全性。理论上,如果黑客以加密用户相同的身份登录系统并获得了加密后的数据,解密将变得相对容易。然而,通过引入描述性字符串和额外熵,这种实时计算出来的加密参数(非直接的明文参数),使攻击者即使在逆向分析破解了二进制程序后,依然很难计算出这些参数。
pvReserved
: 保留参数,必须为NULL。pPromptStruct
: 一个可选的CRYPTPROTECT_PROMPTSTRUCT
结构,用于自定义加密提示。通常可以为NULL。dwFlags
: 控制加密的标志,这些标志可以根据你的具体需求和场景来选择。例如,如果你希望在本地计算机上加密数据并跨多个用户帐户共享,可以结合使用CRYPTPROTECT_LOCAL_MACHINE
。如果需要静默操作而不显示UI提示,可以使用CRYPTPROTECT_UI_FORBIDDEN
。在需要验证数据完整性时,可以使用CRYPTPROTECT_VERIFY_PROTECTION
等。pDataOut
: 用于接收加密后的数据的DATA_BLOB
结构。
封装了 CryptProtectData API 的【EncryptFunc::encrypt】函数讲解:
bool EncryptFunc::encrypt(const std::string& strSalt, const std::string& input, OUT std::vector<unsigned char>& vEncryptedData)
{
if (input.empty())
{
return false;
}
DATA_BLOB dataIn;
DATA_BLOB dataOut;
DATA_BLOB ent = createEntropy(strSalt);
dataIn.pbData = (BYTE*)input.c_str();
dataIn.cbData = (DWORD)input.size();
auto encryptionSucceeded = false;
std::string desc = ObfuscateString("EncryptFunc", 'X');
std::wstring wdesc = MB2WC(desc.c_str());
if ((CryptProtectData(&dataIn, wdesc.c_str(), &ent, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &dataOut)))
{
if ((dataOut.pbData != nullptr) && (dataOut.cbData > 0))
{
vEncryptedData.reserve(dataOut.cbData);
vEncryptedData.assign(dataOut.pbData, dataOut.pbData + dataOut.cbData);
LocalFree(dataOut.pbData);
encryptionSucceeded = true;
}
}
else
{
DWORD dwLastError = ::GetLastError();
std::string errorMessage = "Error code: " + std::to_string(dwLastError);
MessageBoxA(NULL, errorMessage.c_str(), "Error", MB_ICONERROR);
}
freeEntropy(ent);
return encryptionSucceeded;
}
在调用 CryptProtectData 时,传入的参数有4个,传出的有1个,具体如下:
- 【IN】DATA_BLOB dataIn,其大小部分,取自输入的待加密 std::string 类型字符串的尺寸;其指针部分,来源于字符串的内容。
- 【IN】如之前介绍API时所说,为了增加攻击者的破解难度,所以这是一个实时计算出来的参数。例程中先调用私有函数【EncryptFunc::ObfuscateString】,对输入的字符串和字符,进行异或计算进而实现“Obfuscate”(混淆字符串)的目的,使得攻击者难以确定真正作为参数输入的描述性字符串。本例程的混淆字符串实现仅作为演示目的,产品级的混淆操作会非常复杂。
std::string EncryptFunc::ObfuscateString(const std::string& input, char key)
{
std::string obfuscatedString = input;
for (char& character : obfuscatedString)
{
character = character ^ key;
}
return obfuscatedString;
}
又因为 wdesc 是一个 LPCWSTR 类型字符串,所以需要对混淆后的字符串再做一次将 UTF-8 格式转换成宽字符集格式。细心的读者也许会发现整段代码里其实埋有个“雷”,关于怎么解开这个“雷”,笔者有意作为一个专题在番外篇里介绍,此处暂且不表。
wchar_t* EncryptFunc::MB2WC(LPCSTR lpszStrIn)
{
LPWSTR pwszOut = NULL;
if (lpszStrIn != NULL)
{
int nInputStrLen = strlen_safe(lpszStrIn);
int nOutputStrLen = MultiByteToWideChar(CP_UTF8, 0, lpszStrIn, nInputStrLen, NULL, 0) + 1;
pwszOut = new wchar_t[nOutputStrLen];
if (pwszOut)
{
SecureZeroMemory(pwszOut, nOutputStrLen * sizeof(wchar_t));
MultiByteToWideChar(CP_UTF8, 0, lpszStrIn, nInputStrLen, pwszOut, nOutputStrLen);
}
}
return pwszOut;
}
- 【IN】对于额外熵参数,可以参考以下【EncryptFunc::createEntropy】的实现。本质上是基于输入的字符串,构建一个 DATA_BLOB 类型变量。但为了增加破解难度,又对输入的字符串增加了一次异或操作。总之就是想方设法按照加解密过程约定好的算法混淆输入参数,增加破解难度。
DATA_BLOB EncryptFunc::createEntropy(const std::string strSalt)
{
const auto key = strSalt;
DATA_BLOB ent;
ent.cbData = (DWORD)key.length();
ent.pbData = (BYTE*)new char[ent.cbData];
XOREncode(key.c_str(), (char*)ent.pbData);
return ent;
}
- 【IN】由于本例只需要静默操作而不显示UI提示,也不需要跨多个用户帐户共享,所以控制加密的标志选用了
CRYPTPROTECT_UI_FORBIDDEN
- 【OUT】加密后的
DATA_BLOB
结构中的数据部分,按照给定的长度最终赋值给了一个 vector 变量,方便接下来的 Base64 Encode 操作。因为如【图-2】所示,数据部分含有很多【'0'】字符,如果不进行 Encode 操作,直接输出字符串,会造成截断。
【EncryptFunc::encrypt】上一级栈的代码内容讲解:
bool EncryptFunc::encrypt(LPCTSTR lpszSource, std::string& strDest)
{
if (nullptr == lpszSource)
{
return false;
}
std::string input = WC2MB(lpszSource);
if (input.empty())
{
return false;
}
std::vector<unsigned char> vEncryptData;
std::string strSalt = generateSalt();
if (encrypt(strSalt, input, vEncryptData))
{
const auto size = vEncryptData.size();
if (size > 0)
{
char* lpByteBase64 = new char[size * 2];
if (lpByteBase64)
{
ZeroMemory(lpByteBase64, size * 2);
::Base64Encode(lpByteBase64, (const char*)vEncryptData.data(), vEncryptData.size());
strDest = lpByteBase64;
delete[] lpByteBase64;
return true;
}
else
{
delete[] lpByteBase64;
}
}
}
return false;
}
- 如【图-3】所示,加密时作为“额外熵”的参数来自于一段混淆字符串,再拼接上当前用户的SID。其目的还是在增加破解难度,以及添加能代表当前用户唯一性的属性。
- Base64 Encode 之后的内容,就可以方便地作为一个字符串输出,并被最终持久化下来了【图-4】。
CryptUnprotectData API
是Windows操作系统提供的API函数,常用于本地应用程序,解密之前使用 CryptProtectData 所
加密的数据。
DPAPI_IMP BOOL CryptUnprotectData(
[in] DATA_BLOB *pDataIn,
[out, optional] LPWSTR *ppszDataDescr,
[in, optional] DATA_BLOB *pOptionalEntropy,
PVOID pvReserved,
[in, optional] CRYPTPROTECT_PROMPTSTRUCT *pPromptStruct,
[in] DWORD dwFlags,
[out] DATA_BLOB *pDataOut
);
pDataIn
: 要解密的数据,以DATA_BLOB
结构传递,包括数据的指针和大小。pDataOut
: 用于接收解密后的数据的DATA_BLOB
结构。- ppszDataDeser: 【注意!】此处是用于接收描述性字符串,是 LPWSTR 类型,不再是LPCWSTR 类型。一般情况下需要和加密时的描述性字符串进行比较,以确认对应关系。
可以发现 CryptUnprotectData 接口的参数与 CryptProtectData 大同小异,主要区别为输入的是密文,输出的是解密后的内容,并且此处是接收描述性字符串用于手动比较。
【EncryptFunc::decrypt】讲解:
bool EncryptFunc::decrypt(LPCTSTR lpszSource, std::string& strDest)
{
std::string strInput = WC2MB(lpszSource);
const int nSize = strInput.length();
std::string strSalt = generateSalt();
BYTE* lpByte = new BYTE[nSize];
if (lpByte)
{
int len = ::Base64Decode((char*)lpByte, strInput.data(), nSize);
if (len > 0)
{
std::vector<BYTE> vEncryptedData;
vEncryptedData.reserve(len);
vEncryptedData.assign(lpByte, lpByte + len);
delete[] lpByte;
std::string strDecryptedData;
if (decrypt(strSalt, vEncryptedData, strDecryptedData))
{
strDest = strDecryptedData;
return true;
}
}
else
{
delete[] lpByte;
}
}
return false;
}
Base64Decode 将读取的持久化的密文字符串解码,然后反向输出到 vector 变量,传入重载的另一个【EncryptFunc::decrypt】函数,用于进一步调用封装了的 CryptUnprotectData 接口。此外还向重载的函数中传入同样的“盐”,用于接下来在解密前构建同样的“额外熵”。
解密过程的核心细节如【图-5】所示:
- 采用同样的方法构建了“额外熵”用于解密,如果不匹配将解密失败,通常是返回
CRYPT_E_BAD_DATA
或类似的错误码。; - 采用同样的方法产生混淆字符串作为“描述性字符串”,用于和接收到的字符串进行比较,以确认解密的内容和加密内容的对应关系,没有遇到“张冠李戴”的问题;
- 输出的 DATA_BLOB 结构体中指向数据块的指针对应解密后的数据。
//strDecryptedData = (char*)dataOut.pbData;
strDecryptedData = std::string((char*)dataOut.pbData, dataOut.cbData);
需要特别提醒的是,解密后的数据在内存里通常并不是以【‘00’】字节结尾(如本例结尾字节为【‘05’】),按照上面注释行的方法将无法正确赋值。所以需要采用【std::string(const char* s, size_t count)】这种构造方法来进行定长截断。
程序实测结果:
程序的测试结果也符合我们的预期,只有在同一台机器上的同一个用户(执行加密操作的用户)登录下,才能顺利解密【图-6】。除此之外,即使得到了持久化的密文,如果是同一台机器的不同用户,或者在另外一台机器上【图-7】执行相同的程序都会失败。【-2146893813】
这个特定的错误代码对应于 CRYPT_E_NOT_FOUND
错误,它指示在执行 CryptUnprotectData
函数时,未找到指定的数据或密钥。这就是本文开篇所论述的“加解密接口直接使用当前用户的登录凭据操作加密的数据,而无需接口使用者手动管理密钥。”
本例程源码地址:https://github.com/345967018/WBBestPractice028EncryptDecrypt.git