WinAESwithHMAC: 一个 C++ AES/HMAC 类






4.73/5 (6投票s)
一个 C++ 类,使用 Windows CAPI 提供加密和认证。
引言
好人和坏人之间的加密军备竞赛促成了 认证加密 方案的发展。认证加密提供了机密性、完整性和真实性。这意味着我们的数据既可以防止泄露,又可以防止篡改。CodeProject 的 认证加密 探讨了篡改数据的容易程度,并展示了如何使用三种专用的分组密码操作模式 (EAX, CCM, 和 GCM) 来确保机密性和真实性。该解决方案包括使用 Crypto++,这让许多初学者有时感到困难。
作为 Crypto++ 的替代方案,并为了让初学者适应 CAPI 编程,我们开发了 WinAES
。该类仅提供隐私,而不提供真实性——因此,在它的保护下,秘密篡改数据是相对容易的。为了加强 WinAES
,使其能够真正用于应用程序,我们必须添加一个用于数据真实性保证的认证器。为此,我们将为 AES 添加一个 HMAC,以形成一个新类:WinAESwithHMAC
。
WinAESwithHMAC
将建立在 WinAES
的基础上。与 WinAES
一样,该类将仅使用 Windows CAPI 以实现最大的 Windows 互操作性。WinAESwithHMAC
将使用 AES-CBC 和 HMAC-SHA1。我们使用 SHA1 是因为它在 XP 及以上版本可用,尽管我们更倾向于 SHA-256 或 CMAC。CMAC 本质上是正确实现的 CBC-MAC (请参阅 认证加密 以及可变长度消息上的 CBC-MAC 的使用)。
WinAESwithHMAC
仍然面向初学者。但这次,我们还将
- 将两个加密服务提供程序 (CSP) 封装在一个对象中
- 从主密钥或基础密钥中派生两个密钥
- 在
CryptEncrypt
和CryptDecrypt
中使用 HMAC - 使用 WinDbg 窥探 rsaenh.dll 以验证操作的正确顺序
加密数据格式
认证加密方案的输出是对 { 密文, 认证器 } 的配对。密文是常规的加密数据,认证器是密文上的 HMAC,它为密文提供真实性保证。在表示法上,WinAESwithHMAC
输出 C||a
,其中 C = Enc(m)
,a = Auth(C)
。C||a
只是 { 密文, 认证器 } 对。
由于我们希望允许替换仅提供加密的对象(例如 WinAES
),WinAESwithHMAC
将在加密时将认证标签附加到密文。反之,WinAESwithHMAC
将在解密前移除现有标签,然后使用现有标签与新计算的 HMAC 进行比较(HMAC 在解密过程中对密文进行计算)。有关为什么认证器是在密文上计算而不是在明文上计算的详细信息,请参阅 认证加密。
Intel 硬件加密服务提供程序
Intel 硬件加密服务提供程序可作为 下载 以可再发行形式提供。我们将在 WinAESwithHMAC
中使用它作为第二个提供程序,但它对类的正确操作不是必需的。默认情况下,通过构造函数传递的标志不指定加载 Intel CSP。如果指定了 INTEL_RNG
且 Intel CSP 不可用,则类将回退到主 CSP 来生成伪随机字节。我们加载提供程序的代码如下:
static const PROVIDERS IntelRngProvider[] =
{
{ INTEL_DEF_PROV, PROV_INTEL_SEC, 0 }
};
...
for( int i = 0; (m_nFlags & INTEL_RNG) &&
(i < _countof(IntelRngProvider)); i++ )
{
if( CryptAcquireContext( &m_hRngProvider, NULL,
IntelRngProvider[i].params.lpwsz,
IntelRngProvider[i].params.dwType,
IntelRngProvider[i].params.dwFlags ) ) {
break;
}
}
if( NULL == m_hRngProvider ) {
assert( NULL != m_hAesProvider );
m_hRngProvider = DuplicateContext( );
}
使用 Intel 生成器时,请注意该生成器是阻塞的。因此,您可能考虑使用 Intel 生成器来为 AES 提供程序的生成器提供种子。
密钥设置
WinAES
类使用一个密钥,调用 CryptImportKey
将提供的密钥材料插入 Windows 密钥存储。WinAESwithHMAC
需要两个密钥。一个密钥用于加密,一个密钥用于认证。这意味着 WinAESwithHMAC
需要 32 字节的密钥材料来进行 AES-128——16 字节用于加密,16 字节用于认证。我们可以使用 WinAESwithHMAC
中的两种密钥策略之一。首先,我们可以要求调用者在使用 AES-128(以及 AES-256 的 64 字节)进行密钥设置时提供所有 32 字节。我认为这有点不合理,所以我们将使用第二种方法。
第二种方法使用提供的 16 字节(或 AES-256 的 32 字节)作为基础密钥,从中派生出加密密钥和认证密钥。我认为这是最合理的,因为我们知道额外的密钥材料相对安全,因为我们控制着派生过程。我们希望避免(从用户角度)的是密钥不独立的问题。如果攻击者恢复了认证密钥,攻击者就不应该能够确定加密密钥(反之亦然)。如果一个密钥简单地从另一个密钥派生而来,第一个密钥的泄露可能会暴露第二个(派生的)密钥。为了缓解此行为,我们将控制派生过程。
伪随机函数和密钥派生函数允许我们从现有密钥材料创建额外的密钥材料。NIST 通过 SP 800-108 提供了有关 PRF 和 KDF 的指南。毫不奇怪,Microsoft 对密钥派生的实现包含在 CryptDeriveKey
中。当我们随意地说我们“派生密钥”时,在 CryptDeriveKey
的底层有很多事情在发生。但是在我们使用这个函数之前,我们需要了解它的行为。首先,MSDN 的伪代码 示例 C 程序:从密码派生会话密钥。
HASH hash = CryptCreateHash(...)
hash.Update( key material )
KEY key = CryptDeriveKey( hash )
如提供的,该代码既简单又安全(操作词是安全)。MSDN 示例表明,低熵源(如密码)可用于创建会话密钥。到目前为止,一切都很好。接下来,我们按如下方式修改程序以适应会话密钥和 mac 密钥的创建,如下所示。
HASH hash = CryptCreateHash(...)
hash.Update( key material )
KEY session = CryptDeriveKey( hash )
KEY mac = CryptDeriveKey( hash )
当我们检查 session
和 mac
的密钥材料时,我们发现密钥是相同的。对于那些已经熟悉了认证加密的人来说,使用相同的密钥来同时加密数据和认证数据会导致密文与明文无关。认证机制将完全不安全。而且,更糟糕的是,CryptDeriveKey
在使用 AES 提供程序时不会指示任何类型的失败。邪恶的派生过程如图 1 所示。
|
图 1:邪恶的密钥派生
|
因此,我们按如下方式修改程序以确保第二次哈希
HASH hash = CryptCreateHash(...)
hash.Update( key material )
KEY session = CryptDeriveKey( hash )
HASH hash = CryptCreateHash(...)
hash.Update( session key )
KEY mac = CryptDeriveKey( hash )
上面的代码虽然比第一个好,但存在一个问题,即密钥不是独立的——mac 密钥直接从会话密钥派生而来。在上图的方案中,用于派生密钥的方法可能会助长密钥的恢复(本质上,我们向攻击者展示了密钥操作两个阶段的输出)。图 2 显示了该操作。
|
图 2:邪恶的密钥派生
|
为了实现密钥的独立性,我们需要将提供的密钥材料用作“主密钥”,并在派生密钥时结合附加信息。我们期望的伪代码如下。在代码中,Km 是主密钥(一个“基础密钥”),由调用者提供。我们使用 Km 来派生 Ke 和 Ka,分别用于加密和认证。
SetKey( key_m, keysize )
{
k_e = DeriveKey( Hash( key_m, encryption ) )
k_a = DeriveKey( Hash( key_m, authentication ) )
}
在上方的伪代码中,我们在调用 CryptDeriveKey
之前,使用相同的基本材料并附加了常量信息。我们用来实现密钥唯一性的方法类似于 RFC 2898 的 KDF1。如图 3 所示。
|
图 3:密钥派生
|
我们不应直接使用哈希的输出作为会话密钥或 mac 密钥,因为 CryptDeriveKey
将充当 PRF 和 KDF(请参阅关于CryptDeriveKey
函数的密钥派生讨论)。类中的 DeriveKey
函数如下。密钥通过 SetKey
或 SetKeyWithIV
传入,标签是所需密钥的唯一数据。
DeriveKey(const byte* key, unsigned ksize, const byte* label,
unsigned lsize, HCRYPTKEY hKey)
{
if(!CryptCreateHash( hProvider, CALG_SHA1, 0, 0, &hHash)) {
// Handle error
}
if (!CryptHashData( hHash, key, ksize, 0) ) {
// Handle error
}
if (!CryptHashData( hHash, label, lsize, 0) ) {
// Handle error
}
if (!CryptDeriveKey( hProvider, CALG_AES_128, hHash, dwFlags, &hKey)) {
// Handle error
}
}
上面的代码硬编码了 SHA1 和 AES-128,但类代码提供了更大的灵活性。同样值得关注的是 dwFlags
。在 MSDN 示例中,密钥是为流密码(如 RC2)派生的,因此 dwFlags
为 0。对于像 AES 这样的分组密码,我们必须在高级 WORD
中传入所需的密钥大小。因此,对于 AES-128,dwFlags = 128 << 16
,对于 AES-256,dwFlags = 256 << 16
。如果我们想检查派生密钥的唯一性,低位 WORD
将包含 CRYPT_EXPORTABLE
。
HMAC 生成
在检查加密和解密例程的更改之前,我们首先看看如何使用 CAPI 创建 HMAC。MSDN 提供了 创建 HMAC 示例,它构成了我们使用的基础。伪代码如下:
HMAC_INFO info
info.AlgId = CALG_SHA1
HCRYPTHASH hash = CryptCreateHash(..., CALG_HMAC, HMAC key, ...)
CryptSetHashParam(..., HP_HMAC_INFO, info, ...)
我们看到 HMAC 的构造方式与哈希略有不同。最值得注意的是,HMAC 密钥是 CryptCreateHash
的一个参数。
明文和密文大小
WinAESwithHMAC
将在加密期间将 HMAC 附加到密文,并在解密期间移除标签。因此,调用 MaxCipherTextSize
现在包括 HMAC 的附加部分,该部分为 20 字节。请记住,SHA1 是 160 位或 20 字节。对于 MaxPlainTextSize
,准备解密时的情况则相反——大小要求比密文的大小少 20 字节。
加密和解密
加密和解密函数中有两个更改。第一个是使用 HMAC,其创建过程已在上面描述。第二个是将 HMAC 句柄传递给加密(或解密)函数,以便 CAPI 可以同时加密和哈希。
加密
我们对加密函数的调用如下。在下面的代码中,WinAES
会将 NULL
传递给 CryptEncrypt
,而 WinAESwithHMAC
将传递一个哈希对象的句柄。
HCRYPTHASH hash = NULL;
if( !CryptCreateHash(m_hAesProvider, CALG_HMAC, m_hHmacKey, 0, &hHash)) {
// Handle error
}
if( !CryptSetHashParam(hHash, HP_HMAC_INFO, (byte*)&info, 0)) {
// Handle error
}
// CAPI overwrites plain text size
DWORD dsize = psize;
if( !CryptEncrypt( m_hAesKey, hHash, TRUE, 0, buffer, &dsize, bsize )) {
// Handle error
}
成功加密后,剩下的就是将 HMAC 附加到密文,如下所示。CAPI 在加密过程中计算的 HMAC 是一个对密文的标签。有关为什么认证器是在密文上计算而不是在明文上计算的详细信息,请参阅 认证加密。
DWORD hsize = HMAC_TAGSIZE;
if (!CryptGetHashParam( hHash, HP_HASHVAL, &buffer[dsize], &hsize, 0)) {
// Handle error
}
dsize
是从 CryptEncrypt
收到的输出参数,表示写入缓冲区的密文大小。&buffer[dsize]
被传递给 CryptGetHashParam
,因为它是紧跟在密文之后的第一个字节。
解密
解密与加密非常相似,不同之处在于我们必须根据附加到密文的 HMAC 的大小来调整密文的大小。另外请注意,HMAC 是在我们提供给解密例程的密文上计算的。解密完成后,我们必须将现有 HMAC 与计算出的 HMAC 进行比较。因此,我们的解密函数如下:
HCRYPTHASH hash = NULL;
if( !CryptCreateHash(m_hAesProvider, CALG_HMAC, m_hHmacKey, 0, &hHash)) {
// Handle error
}
if( !CryptSetHashParam(hHash, HP_HMAC_INFO, (byte*)&info, 0)) {
// Handle error
}
// Adjust cipher text size
DWORD dsize = (DWORD)csize-HMAC_TAGSIZE;
if( !CryptDecrypt( m_hAesKey, hHash, TRUE, 0, buffer, &dsize, bsize )) {
// Handle error
}
成功解密后,我们检索计算出的 HMAC(基于提供的密文),并将其与现有 HMAC 进行比较,如下所示:
DWORD hsize = HMAC_TAGSIZE;
BYTE hash[ HMAC_TAGSIZE ];
// Retrieve hash calculated over recivered text
if (!CryptGetHashParam( hHash, HP_HASHVAL, hash, &hsize, 0)) {
// Handle error
}
// Verify hash
if( !(0 == memcmp( hash, &buffer[csize-hsize], hsize )) ) {
// Handle error
}
AES 提供程序的运行顺序是什么?
阅读 认证加密 后,我们知道结合加密和认证有两种普遍接受的方法(下面重现了 认证加密 表格的一部分)。在某些条件下,先认证后加密 (AtE) 在某些构造下是安全的(黄色),而先加密后认证 (EtA) 始终是安全的(绿色)。
方法 | 操作 (Operation) | 结果 |
先认证后加密 (AtE) | a = Auth(m), C = Enc(m||a) | C |
先加密后认证 (EtA) | C = Enc(m), a = Auth(C) | C||a |
那么,我们的问题是,AES 加密服务提供程序使用哪种运行顺序? 要回答这个问题,我们必须求助于 WinDbg。我们在 WinDbg 调试器下运行 Sample.exe。程序启动后,调试器立即中断。我们利用此中断来设置我们的第一个断点,然后检查加载的模块。
|
图 4:初始 WinDbg 断点
|
我们输入的命令如上所示,用蓝色字体显示。lm 列出了已加载的模块(请注意,rsaenh.dll 尚未加载)。然后我们尝试在 Sample!WinAESwithHMAC::Encrypt
上设置断点,这会提示 WinDbg 提供更多信息。因此,我们发出 bp 0044b2d0
(我们希望在使用公共缓冲区的 Encrypt
上中断)。然后我们按 g 运行程序。如果一切顺利,我们应该看到的下一个提示是已遇到断点。
Breakpoint 0 hit
eax=0012f978 ebx=7ffd8000 ecx=0012fe38 edx=0012f990 esi=004c8119 edi=0012fded
eip=0044b2d0 esp=0012f868 ebp=0012fe80 iopl=0 nv up ei pl nz na po nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000202
Sample!WinAESwithHMAC::Encrypt:
0044b2d0 55 push ebp
由于我们正准备加密,rsaenh 模块必须已加载(直到 Sample.exe 调用 Windows CryptAcquireContext
时才加载)。因此,我们查询模块中导出的符号,如图 5 所示。我们已经知道 rsaenh.dll 中感兴趣的函数名以CP
开头,所以我们输入 xrsaenh!CP*
。
|
图 5:我们感兴趣的 RSA Enhanced Provider 导出符号
|
由于我们想知道运行顺序,我们的下一个两个断点是 rsaenh!CPEncrypt
和 rsaenh!CPHashData
。我们设置断点,然后观察程序执行,如图 6 所示。
|
图 6:在 WinDbg 中观察到的运行顺序
|
从 WinDbg 的输出中可以看出,AES Enhanced Provider 首先执行加密,然后执行认证。因此,我们知道 Microsoft 正在使用可证明安全的先加密后认证。
示例程序
示例程序可作为 Sample.zip 下载,如下所示。表面上,它与 WinAES 中的示例程序(仅执行加密)没有区别,只是为了练习从公共缓冲区加密和解密的功能。然而,现在几乎不可能在不被发现的情况下篡改密文。
WinAESwithHMAC aes;
byte key[ WinAESwithHMAC::KEYSIZE_256 ];
byte iv[ WinAESwithHMAC::BLOCKSIZE ];
aes.GenerateRandom( key, sizeof(key) );
aes.GenerateRandom( iv, sizeof(iv) );
char message[] = "Microsoft AES Cryptographic Service "
"Provider test using AES-CBC/HMAC-SHA1";
// Arbitrary size
const int BUFFER_SIZE = 1024;
byte buffer[BUFFER_SIZE];
size_t psize = strlen(message)+1;
size_t csize=0, rsize=0;
// Copy plain text into common buffer
memcpy_s(buffer, BUFFER_SIZE, message, psize);
// Set the key and IV
aes.SetKeyWithIv( key, sizeof(key), iv, sizeof(iv) );
// Done with key material - CSP owns it now...
SecureZeroMemory( key, sizeof(key) );
// Encrypt in place
if( !aes.Encrypt(buffer, BUFFER_SIZE, psize, csize) ) {
cerr << "Failed to encrypt plain text" << endl;
}
/////////////////////////////////////////
// Tamper
// buffer[0] ^= 1;
/////////////////////////////////////////
// Decrypt in place
if( aes.Decrypt(buffer, BUFFER_SIZE, csize, rsize) ) {
cout << "Recovered plain text" << endl;
}
else {
cerr << "Failed to decrypt plain text" << endl;
}