WinAES:一个 C++ AES 类






4.86/5 (20投票s)
又一个 C++ 类包装器,用于 AES 和 Windows CAPI。
引言
本文档是 Windows CAPI 编程的介绍。令我惊讶的是,CodeProject 的 安全版块 中竟然没有一篇适合初学者的 CAPI 文章。同时,我也很困惑,竟然没有一个“即插即用”的 C++ AES 包装器来支持 CAPI。本文档将尝试解决这两个问题,特别是对初学者而言。
本文档将开发一个名为 WinAES
的简单 C++ 包装器,它允许我们使用 Windows 加密 API 以 CBC 模式(和 PKCS #5 填充)加密和解密数据。该类将演示如何使用 CryptImportKey
将现有密钥材料导入容器,而不是像 MSDN 示例那样使用 CryptGenKey
或 CryptDeriveKey
。
也有 CAPI 的替代方案。例如,微软提供了 .NET 安全 API 和 NG,而第三方则提供像 Java、OpenSSL、Crypto++ (Wei Dai) 和 Cryptlib (Peter Guttman) 这样的库。如果使用 .NET API 并需要 FIPS 140 合规性,请务必检查 .NET 类是否是 CAPI 调用的包装器,**而不是**托管实现。CAPI 实现已通过 FIPS 认证,而托管实现则没有。一些(但并非全部)System.Security.Cryptography
类会调用 CAPI。例如,SHA1CryptoServiceProvider
类 由于调用 CAPI 而符合 FIPS 标准,而 SHA1
类 则不符合。要获取 47 个认证模块的完整列表,请参阅 NIST 的 FIPS 140 供应商列表。
虽然这是一个温和而全面的介绍,但仅使用加密通常不足以满足应用程序的需求。有关仅使用加密的不足之处的讨论,请参阅 认证加密。要获得提供加密和认证的即插即用 WinAES 替换版本,请参阅 WinAESwithHMAC。如前所述,这是一篇面向初学者的文章。因此,中级和高级读者可能会觉得内容枯燥乏味。
背景
AES 是高级加密标准。该算法由 Joan Daemen 和 Vincent Rijmen 开发。AES 是一种 128 位分组密码,可以使用 128、192 和 256 位密钥。由于密钥长度可变但分组长度固定,因此在讨论 AES 时,遇到 AES-128、AES-192 和 AES-256 并不罕见。
AES 是 NIST 批准供美国政府使用的最新分组密码。该算法还被 NESSIE(欧洲密码学标准组织)和 ISO/IEC(世界标准组织)批准使用。
Windows CAPI
程序员在进行安全编程时看到的正面接口是 CAPI,即加密 API。其底层是一个非常灵活的架构,可以通过 SSPI,即 安全支持提供程序接口 实现扩展。Technet 在 安全支持提供程序接口 中对该架构进行了详细的审查。
SSPI 的具体实现是 SSP,即 安全支持提供程序。SSP 通过 DLL 向应用程序提供安全包 (SP)。SP 通过 DLL 处理上下文管理、凭据管理以及安全协议(协议示例包括 RPC、NTLM 和 Kerberos)之间的身份验证等操作。加密服务提供程序 (CSP) 是 SSP 的一个方面或一个视图。
从上述讨论中可以清楚地看出,为什么像 Crypto++ 和 OpenSSL 这样的库通常比 CAPI 更易于使用——它们功能较少,并且不遵循任何特定的实现接口。例如,Crypto++ 和 OpenSSL 都不提供用于任何类型密钥管理的密钥存储,而 CSP 则必须提供。另一方面,Java 确实提供了密钥管理,并且使用起来稍微困难一些。
服务提供程序
当我们使用微软的 AES 实现时,我们使用了一个 DLL 的服务,该 DLL 提供了 AES 算法并符合 SSPI 规范。动态链接库不是直接加载的。相反,当我们通过 pszProvider
和 dwProvType
调用 CryptAcquireContext
时,我们会间接指定 DLL。这引出了一个问题:我们如何知道要请求或使用哪个 CSP? 简单的答案是,我们搜索 微软加密服务提供程序 列表。下面,除了 AES 和 DSS 之外,我们还看到了 Base、Strong 和 Enhanced 等经典提供程序。
|
图 1:微软加密服务提供程序
|
如图 2 所示,AES 提供程序算法 显示支持 AES-128、AES-192 和 AES-256。
|
图 2:AES 加密服务提供程序算法
|
但是,微软增强型加密提供程序 也可能支持 AES 算法。但是,当我们访问支持的算法页面(下图 3)时,我们发现此程序包**不支持** AES。
|
图 3:微软增强型加密提供程序算法
|
看来我们不仅仅是在包装 AES,或是 CAPI 的 CryptEncrypt
和 CryptDecrypt
——我们是在包装服务提供程序。如果我们想使用 AES 和 HMAC 来实现认证加密,我们可以将两个提供程序包装在一个对象中:AES 提供程序 (AES) 和增强型提供程序 (HMAC)。严格来说,第二个提供程序并非必需,因为 AES 提供程序同时提供了这两种算法。
可用服务提供程序
微软的 CAPI 允许我们在运行时枚举可用的提供程序。但对于本文档,我们可以查看注册表下的 *HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography\Defaults\Provider* 来检查我们可用的内容。图 4 显示了一个 Windows XP 安装。
|
图 4:已安装的加密服务提供程序
|
上面列出的最有趣的提供程序之一是 Intel 硬件加密服务提供程序。Intel 提供程序不是由操作系统安装的。然而,它是一个可再发行包,并随本文档一起提供 下载。可再发行包包含 icsp4ms.h,其中有以下定义:
/* Intel Chipset CSP type */
#define PROV_INTEL_SEC 22
/* Intel Chipset CSP name */
#define INTEL_DEF_PROV_A "Intel Hardware Cryptographic Service Provider"
#define INTEL_DEF_PROV_W L"Intel Hardware Cryptographic Service Provider"
#ifdef UNICODE
#define INTEL_DEF_PROV INTEL_DEF_PROV_W
#else
#define INTEL_DEF_PROV INTEL_DEF_PROV_A
#endif
Intel 的 CSP 响应五个 CAPI 函数调用:CryptAcquireContext
、CryptReleaseContext
、CryptGetProvParam
、CryptSetProvider
和 CryptGenRandom
。使用该提供程序的代码如下。如果硬件生成器不可用,线程的 last error 将设置为 ERROR_DEV_NOT_EXIST
。
// Get a handle to the Intel CSP
If(!CryptAcquireContext(&hProvider, NULL, INTEL_DEF_PROV, PROV_INTEL_SEC, 0))
{
// Handle error
...
}
// Get a random number
if(!CryptGenRandom(hProv, randomLength, (BYTE*)&randomNumber))
{
// Handle error
...
}
使用 Intel 的生成器时,请务必阅读可再发行包中提供的 AccessDocumentation.doc。
Intel 的安全驱动程序也可能引起兴趣,该驱动程序可从 http://developer.intel.com/design/software/drivers/platform/security.htm 下载。安全驱动程序可访问部分芯片组的硬件生成器,如 Intel 的 810 芯片组系列、815 芯片组系列、Intel 830 芯片组系列和 845G 芯片组系列。
WinAES
完成了前期的准备工作,我们可以将注意力转向 C++ 包装器。在使用 CAPI 时,我的两个抱怨是:
- 各提供程序之间的行为不一致,并且
- 当我们只收到
CryptEncrypt
或CryptDecrypt
返回的ERROR_INVALID_PARAMETER
时,很难判断哪里出了问题。
WinAES
将通过彻底验证其参数和返回值来解决这两个问题。将很难以会导致失败(或在不同 DLL 之间表现出不同行为)的配置调用 CryptEncrypt
或 CryptDecrypt
。
如果您阅读过 John Robbin 的任何一本 《调试应用程序》,您就会熟悉大量的断言。如果您没有阅读过,当出现问题时,您将获得一次精彩的体验。由于这是一篇面向初学者的文章,您应该购买 John 的书。熟练使用 Visual Studio 调试器时,它能完成令人惊讶的事情,而这本书将有助于培养这些技能。
类代码中有 55 个断言。几乎所有内容都经过验证——从传入参数到函数返回值。一旦出现问题,我们会立即知道。他们说最好的代码是你无需编写的代码。他们是对的,但他们忘了提到第二好的代码——能够自我调试的代码。断言是我们用来创建自调试代码的工具。
WinAESException
WinAES
使用 WinAESException
,它继承自标准异常。在内部,该类频繁使用它(有人告诉我 goto
是不好的风格,所以我必须为挑剔的人伪装它)。根据需要,WinAESException
会在函数中被重新抛出,或者函数返回 false
。具体哪种行为取决于下面的描述。
由于我们在内部捕获了 WinAESException
,我们已经实施了异常处理。然而,我们从不捕获“...”或其他我们未准备好处理的内容。这是良好的编程习惯,我们一直遵守。
然后,当你开始迭代 2(这是构建迭代的开始)时,你可能想要复制测试用例并将它们重新分类到迭代 2。这还允许对测试用例进行粒度跟踪,并允许你说某个测试用例在一个迭代中是准备好的,但在另一个迭代中不是。同样,如何做到这一点取决于你以及你希望如何报告。 “场景”部分提供了更多细节。
WinAES( const wchar_t* lpwszContainer=NULL, int nFlags=DEFAULT_FLAGS )
构造函数接受容器名称和标志。如果我们不指定容器名称,对象将使用“Temporary - OK to Delete”(临时 - 可删除)。这是有道理的,因为我们不希望将不必要的测试密钥污染 CSP 的默认容器。
lpszContainer
允许我们指定一个容器,如果首选的不是默认 CSP 容器(NULL
使用默认)。由于这是一个 C++ 对象,对象必须在出错时抛出异常。然而,在其他时候,我更喜欢迭代式的方法:我希望方法在出错时返回 false
。我们还应该能够指定是否删除容器(如果不是 NULL
)。nFlags
控制行为,其中 DELETE_CONTAINER=1
,THROW_EXCEPTION=2
。DEFAULT_FLAGS
只删除容器。
最后,在构造期间调用 CryptAcquireContext
。MSDN 显示我们应该使用提供程序名称和类型调用 CryptAcquireContext
,如果失败,则使用 CRYPT_NEWKEYSET
再次调用它。此外,XP 和非 XP 机器的提供程序略有不同。我们可以动态确定操作系统版本并调用 MS_ENH_RSA_AES_PROV
或 MS_ENH_RSA_AES_PROV_XP
,但尝试两者更容易。我个人偏好在编译时尽可能多地完成工作。代码可移植性强,维护量小(也能在 Windows CE 上运行)。最后,这也避免了深层嵌套的 if
语句(在我看来,这并不优雅)。
typedef struct PROV_PARAMS_T
{
const WCHAR* lpwsz;
DWORD dwType;
DWORD dwFlags;
} PROV_PARAMS, PPROV_PARAMS;
typedef struct PROVIDERS_T {
PROV_PARAMS params;
} PROVIDERS, PPROVIDERS;
最后,我们声明一个 PROVIDERS
数组并使用 XP/非 XP 的变体初始化它,以及创建新容器和打开现有容器,如下所示。我们按照获取提供程序的顺序初始化元素。例如,我们更倾向于打开现有容器而不是创建新容器。
const PROVIDERS AesProviders[] =
{
{ MS_ENH_RSA_AES_PROV, PROV_RSA_AES, 0 },
{ MS_ENH_RSA_AES_PROV, PROV_RSA_AES, CRYPT_NEWKEYSET },
{ MS_ENH_RSA_AES_PROV_XP, PROV_RSA_AES, 0 },
{ MS_ENH_RSA_AES_PROV_XP, PROV_RSA_AES, CRYPT_NEWKEYSET },
};
要获取提供程序的句柄,我们执行以下操作。_countof
运算符由编译器提供(在 Visual Studio 2005 及更高版本中可用),并且可以正确处理指针。
for( int i = 0; i < _countof(AesProviders); i++ )
{
if( CryptAcquireContext(
&hProvider, lpszContainer,
AesProviders[i].params.lpwsz,
AesProviders[i].params.dwType,
AesProviders[i].params.dwFlags ) )
{
nIndex = i;
break;
}
}
我们必须保留获取提供程序的索引,以防需要删除密钥集。虽然在构造函数中打开任何合适的提供程序的策略是可接受的,但我们不能在析构函数中使用相同的方法删除容器。我们必须确切知道使用了什么提供程序/类型,以便删除确切的密钥集。
密钥设置
bool SetKey( const byte* key, int ksize = KEYSIZE_128 )
bool SetIV( const byte* iv, int vsize=BLOCKSIZE )
bool SetKeyWithIV( const byte* key, int ksize, const byte* iv, int vsize=BLOCKSIZE )
我们有三种可用的密钥大小。AES-128 对应枚举值 KEYSIZE_128
。其余大小 KEYSIZE_192
和 KEYSIZE_256
代表 AES-192 和 AES-256。初始化向量是 128 位(BLOCKSIZE
)。
我们可以调用 SetKey
/SetIV
对,也可以调用 SetKeyWithIV
。如果调用前者,则必须在设置 IV 之前设置密钥。一些 DLL 允许我们进入一种配置,其中选择了 CBC 模式,设置了密钥,但未设置 IV;加密操作**不会**如预期那样失败。这是由于 CSP 缺乏状态验证。我们在 WinAES
对象中纠正了这种行为。
我们可以随时通过调用 SetIV
来重新同步对象。我们不必导入新密钥,也不必重新导入现有密钥来加载新的 IV。
CryptImportKey
CryptImportKey
期望密钥采用特定格式。该函数期望的格式是 BLOBHEADER
,后跟密钥大小(以字节为单位),再后跟实际的密钥材料。根据密钥大小,使用 CryptImportKey
导入的字节数会因实际密钥的字节数而异。我们可以通过两种方式实现:简单的方法(堆栈分配)或困难的方法(运行时分配)。显然,我们将使用简单的方法。运行时分配方法留给读者作为练习。
为了使用堆栈分配,我们需要一个名为 AesKey
的辅助结构。为了容纳最大的密钥,我们将结构定义为针对 AES-256,并在调用 CryptImportKey
之前在运行时调整其大小。我们总可以将较少的密钥材料放入结构中。因此,声明如下。
typedef struct _AesKey
{
BLOBHEADER Header;
DWORD dwKeyLength;
// Set to max possible key size
BYTE cbKey[KEYSIZE_256];
_AesKey() {
ZeroMemory( this, sizeof(_AesKey) );
Header.bType = PLAINTEXTKEYBLOB;
Header.bVersion = CUR_BLOB_VERSION;
Header.reserved = 0;
}
~_AesKey() {
SecureZeroMemory( this, sizeof(_AesKey) );
}
}
struct
和 C++ 中的 class
之间的唯一区别是成员的默认可见性——struct
是 public
,而 class
是 private
。因此,我们将 struct
视为 class
,并提供一个构造函数来初始化 BLOBHEADER
,以及一个销毁器来清除内存中的任何密钥材料。我们还关闭了销毁器的优化,以便 SecureZeroMemory
的调用不会被标记为死代码而被移除。我们还清除 BLOBHEADER
(尽管注重性能的挑剔者可能会抱怨)并没有什么问题。
#pragma optimize( "", off )
// No dead code removal. Key material must be scrubbed
~_AesKey() {
SecureZeroMemory( this, sizeof(_AesKey) );
}
// Restore previous optimizations
#pragma optimize( "", on )
另外请注意,当 MSDN 示例代码调用 CryptGenKey
或 CryptDeriveKey
,然后导出密钥时,它导出的是一个 _AesKey
(在这种特定情况下使用 AES)。密钥材料前面是适当的 BLOBHEADER
和长度。BLOBHEADER
和 dwKeyLength
是 Raphael Amorim 在他的 CodeProject 文章 使用 CryptoAPI 获取明文会话密钥 中跨越的部分。为了完整起见,下面显示了来自 wincrypt.h 的 BLOBHEADER
。
typedef struct _PUBLICKEYSTRUC {
BYTE bType;
BYTE bVersion;
WORD reserved;
ALG_ID aiKeyAlg;
} BLOBHEADER, PUBLICKEYSTRUC;
解释了 AesKey
结构后,我们现在可以检查 SetKey
。该函数的签名是 SetKey( const byte* key, int ksize )
。下面,我们将结构放在堆栈上,并根据参数 ksize
立即填充剩余的结构字段。ksize
将是 16、24 或 32,具体取决于调用者使用的是 AES-128、AES-192 还是 AES-256。
AesKey aeskey;
switch( ksize )
{
case KEYSIZE_128:
aeskey.Header.aiKeyAlg = CALG_AES_128;
aeskey.dwKeyLength = KEYSIZE_128;
break;
case KEYSIZE_192:
aeskey.Header.aiKeyAlg = CALG_AES_192;
aeskey.dwKeyLength = KEYSIZE_192;
break;
case KEYSIZE_256:
aeskey.Header.aiKeyAlg = CALG_AES_256;
aeskey.dwKeyLength = KEYSIZE_256;
break;
default:
// Handle error
...
}
我们使用安全的内存复制将密钥从调用者复制到结构中:memcpy_s(aeskey.cbKey, aeskey.dwKeyLength, key, ksize)
。请记住,要复制的密钥材料将在函数退出时被销毁器清除。
然后,我们调整结构的大小。使用 128 位密钥时,structsize
为 0x1C,使用 256 位密钥时,structsize
为 0x2C。192 位密钥正好介于两者之间。
const unsigned structsize = sizeof(aeskey) - KEYSIZE_256 + ksize;
// Import AES key
if(!CryptImportKey(hProvider, (CONST BYTE*)&aeskey, structsize, NULL, 0, &hAesKey ) )
{
// Handle error
...
}
// Key import success
CryptSetKeyParam
导入密钥后,我们会调用 CryptSetKeyParam
以确保密码以 CBC 模式运行。尽管这是多余的(它应该是默认模式),但我们不会冒险让 DLL 产生非预期的或未公开的行为。
// Set Mode
DWORD dwMode = CRYPT_MODE_CBC;
if(!CryptSetKeyParam( hAesKey, KP_MODE, (BYTE*)&dwMode, 0 ))
{
// Handle error
...
}
加密/解密
我们即将进行加密和解密。在加密之前,我们需要知道密文所需的缓冲区大小。当以 CBC 模式操作密码时,明文必须以明确的方式填充到密码的分组大小,以便以后可以删除填充。PKCS #5 是一种这样的方案。我们不应用或删除填充,但我们需要知道 CAPI 如何操作,以便提供适当大小的缓冲区。
PKCS #5
PKCS #5 的工作方式如下:如果需要填充 1 字节,则将 0x01 追加到明文。如果需要 2 字节,则将 0x02、0x02 追加到明文。这样就留下了一种情况:当需要 0 字节时该怎么做。在这种情况下,将 0x16 追加 16 次。尽管有些违反直觉,但这可以实现无歧义的填充移除。
基于对 PKCS #5 的了解,WinAES
提供了两个用于确定明文和密文大小的函数:MaxCipherTextSize
和 MaxPlainTextSize
。由于在解密之前我们无法知道要删除多少填充,因此 MaxPlainTextSize
始终返回密文大小。缓冲区将总是有点过大,但不会超过 BLOCKSIZE
,因为 BLOCKSIZE
是必须删除的最大填充量。
加密
有两个重载用于加密。一个允许就地加密缓冲区,另一个使用两个不同的缓冲区。如果使用第二个重载(两个缓冲区),则缓冲区不得重叠。
bool Encrypt(byte* buffer, size_t bsize, size_t psize, size_t& csize)
bool Encrypt(const byte* plaintext, size_t psize, byte* ciphertext, size_t& csize)
使用通用缓冲区时,bsize
是缓冲区大小,psize
是明文大小。成功返回后,csize
是密文大小。使用不同缓冲区的场景更简单,因为它只使用 psize
和 csize
。如果对象**未**配置为抛出异常,我们会收到 true
/false
返回值。否则,我们必须准备捕获 WinAESException
。
解密
有两个重载用于解密。第一个允许就地解密缓冲区,第二个使用两个不同的缓冲区。如果使用第二个重载(两个缓冲区),则缓冲区不得重叠。
bool Decrypt(byte* buffer, size_t bsize, size_t csize, size_t& psize)
bool Decrypt(const byte* ciphertext, size_t csize, byte* plaintext, size_t& psize)
使用通用缓冲区时,bsize
是缓冲区大小,psize
是明文大小。成功返回后,psize
是明文大小。
示例程序
现在 WinAES
已经介绍完毕,我们可以看看这个类的实际应用了。该类确实提供了对 CSP 的 CryptGenRandom
函数的访问,因此我们在下面使用它来生成密钥和 IV。下面的示例程序(包含 WinAES
)可供 下载。为了减少显示的代码量,已省略异常处理和错误处理。
WinAES aes;
byte key[ WinAES::KEYSIZE_128 ];
byte iv[ WinAES::BLOCKSIZE ];
aes.GenerateRandom( key, sizeof(key) );
aes.GenerateRandom( iv, sizeof(iv) );
aes.SetKeyWithIv( key, sizeof(key), iv, sizeof(iv) );
char plaintext[] = "Microsoft AES Cryptographic Service Provider test";
byte *ciphertext = NULL, *recovered = NULL;
size_t psize=0, csize=0, rsize=0;
psize = strlen( plaintext ) + 1;
if( aes.MaxCipherTextSize( psize, csize ) ) {
ciphertext = new byte[ csize ];
}
if( !aes.Encrypt( (byte*)plaintext, psize, ciphertext, csize ) ) {
cerr << "Failed to encrypt plain text" << endl;
}
if( aes.MaxPlainTextSize( csize, rsize ) ) {
recovered = new byte[ rsize ];
}
if( !aes.Decrypt( ciphertext, csize, recovered, rsize ) ) {
cerr << "Failed to decrypt cipher text" << endl;
}
...