3.1 简介
Openssl 提供的 EVP API 提供了高层级、相对抽象的密码加密接口,是我们最常用的 Openssl 模块之一。它提供了丰富的
功能和特性:
(1)一系列抽象的密码接口,用户无需考虑底层复杂的实现,使用简单;
(2)支持对称加密和解密操作,并且支持使用对称算法的各种常用的模式;
(3)支持消息摘要算法;
(4)支持非对称加解密和签名验签;
(5)支持密钥派生;
(6)支持生成消息认证码。
3.2 EVP API 的调用规律
我们从上一章节的内容可以知道,调用高层次的 EVP API 基本都遵循固定的规律:
(1)获取算法实现;
(2)创建上下文 CTX;
(3)调用 EVP_***Init;
(4)调用 EVP_***Update;
(5)调用 EVP_***Final;
(6)销毁上下文,清理资源。
虽然表面看起来确实如此,但是对于很多大部分公开密钥算法而言,由于这些算法的数学原理大相径庭,所以将它们抽象成
类似对称算法和摘要算法那样形式的接口非常的困难,所以以上规律在我们使用非对称公开密钥算法时并不适用,这里各位读者
需要注意,以后有机会我会对公开密钥算法的调用做专门的说明。
3.3 对称加密系列接口
在 EVP API 中,和对称算法相关的接口的前缀都是 EVP_CIPHER,我从这里面挑出我认为比较常用的几个接口来说明一下。
首先是获取算法接口:
#include <openssl/evp.h>
/**
* 获取算法
*
* ctx[in] -- Openssl 库上下文
* algorithm[in] -- 算法名称
* properties[in] -- 属性查询字符串
*
* 成功找到算法,返回算法实现,否则返回 NULL
*/
EVP_CIPHER *EVP_CIPHER_fetch(OSSL_LIB_CTX *ctx, const char *algorithm, const char *properties);
/**
* 销毁算法
*
* cipher[in] -- 算法实现
*/
void EVP_CIPHER_free(EVP_CIPHER *cipher);
调用 EVP_CIPHER_fetch 接口第一个参数可以传 NULL,会默认采用全局库上下文。
使用这个接口获取算法在 Openssl 内部被称为“显式获取(Explicit fetching)”,不同于旧版本的直接指定算法实现的方式,新版
本在调用 EVP_CIPHER_fetch 获取算法后,需要调用 EVP_CIPHER_free 接口来释放 EVP_CIPHER 对象。
调用 EVP_CIPHER_fetch 接口获取到算法实现后,我们可以通过该实现获取一系列的对称算法信息,比如密钥长度、初始向量
长度、分组长度等,接口说明如下:
#include <openssl/evp.h>
/**
* 获取分组长度
*
* cipher[in] -- 算法实现
* 返回分组长度
*/
int EVP_CIPHER_get_block_size(const EVP_CIPHER *cipher);
/**
* 获取密钥长度
*
* cipher[in] -- 算法实现
* 返回密钥长度
*/
int EVP_CIPHER_get_key_length(const EVP_CIPHER *cipher);
/**
* 获取初始化向量长度
*
* cipher[in] -- 算法实现
* 返回初始化向量长度
*/
int EVP_CIPHER_get_iv_length(const EVP_CIPHER *cipher);
具体的调用示例如下 -- 示例1:
#include <openssl/evp.h>
#include <trace/trace.h>
int main(int argc, char *argv[])
{
int klen = 0;
int ilen = 0;
int blen = 0;
EVP_CIPHER *sm4 = EVP_CIPHER_fetch(NULL, "SM4-CBC", NULL);
if (!sm4) {
return 0;
}
klen = EVP_CIPHER_get_key_length(sm4);
ilen = EVP_CIPHER_get_iv_length(sm4);
blen = EVP_CIPHER_get_block_size(sm4);
TRACE("key len = %d, iv len = %d, block len = %d\n", klen, ilen, blen);
EVP_CIPHER_free(sm4);
return 0;
}
结果如下:
key len = 16, iv len = 16, block len = 16
可以看到我们正确获取到了 SM4 算法分组链接模式的算法信息。
在使用获取到的算法实现进行运算之前,我们需要获取到加密上下文 -- cipher context,对应 Openssl 中则是 EVP_CIPHER_CTX
对象,创建和销毁上下文的接口说明如下:
#include <openssl/evp.h>
/**
* 创建加密上下文
*
* 返回加密上下文对象
*/
EVP_CIPHER_CTX *EVP_CIPHER_CTX_new(void);
/**
* 销毁加密上下文
*
* ctx[in] -- 加密上下文对象
*/
void EVP_CIPHER_CTX_free(EVP_CIPHER_CTX *ctx);
加密上下文相当于执行加密运算时的环境,里面维护了加解密运算时需要的临时数据,在执行加密运算初始化前,必须首先创建
加密上下文,我们还是以 SM4-CBC 算法为例,先看一下完整的示例程序 -- 示例 2:
#include <openssl/evp.h> #include <trace/trace.h> /* 加密和解密标记 */ #define ENCRYPT 1 #define DECRYPT 0 /* 定义缓冲区长度 */ #define DATA_BUF_LEN 256 /* 全局缓冲区 */ static unsigned char DATA_BUF[DATA_BUF_LEN] = { 0 }; /* 定义二进制数据块 */ struct BIN_DATA { unsigned char *data; /* 数据首地址 */ int len; /* 数据长度(字节) */ }; /* 加密或解密 */ int cipher( EVP_CIPHER_CTX *ctx, EVP_CIPHER *cipher, const struct BIN_DATA *in, struct BIN_DATA *out, int enc) { unsigned char key[] = { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x00 }; unsigned char iv[] = { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0xFF }; int padlen = 0; if (!ctx || !cipher || !in || !out) { return 0; } if (EVP_CipherInit_ex2(ctx, cipher, key, iv, enc, NULL) != 1) { return 0; } if (EVP_CipherUpdate(ctx, out->data, &out->len, in->data, in->len) != 1) { return 0; } if (EVP_CipherFinal_ex(ctx, out->data + out->len, &padlen) != 1) { return 0; } /* 计算最终长度 */ out->len += padlen; return 1; } int main(int argc, char *argv[]) { char data[] = "12345678901234567890abcdefgABCDEFGIOPBNM1235678"; int len = sizeof(data); /* 原文数据 */ struct BIN_DATA in = { (unsigned char *)data, len }; /* 加密缓冲区 */ struct BIN_DATA enc = { DATA_BUF, DATA_BUF_LEN / 2 }; /* 解密缓冲区 */ struct BIN_DATA dec = { DATA_BUF + DATA_BUF_LEN / 2, DATA_BUF_LEN / 2 }; /* 加密上下文 */ EVP_CIPHER_CTX *ctx = NULL; /* 获取算法 */ EVP_CIPHER *sm4 = EVP_CIPHER_fetch(NULL, "SM4-CBC", NULL); if (!sm4) { return 0; } /* 创建加密上下文 */ ctx = EVP_CIPHER_CTX_new(); if (!ctx) { goto end; } /* 加密 */ if (cipher(ctx, sm4, &in, &enc, ENCRYPT) != 1) { TRACE("加密失败!\n"); goto end; } /* 重置上下文 */ if (EVP_CIPHER_CTX_reset(ctx) != 1) { TRACE("上下文重置失败!\n"); goto end; } /* 解密 */ if (cipher(ctx, sm4, &enc, &dec, DECRYPT) != 1) { TRACE("解密失败!\n"); goto end; } /* 打印信息 */ /* 打印原文数据 */ TRACE_BIN("原文数据", in.data, in.len); /* 打印加密数据 */ TRACE_BIN("加密数据", enc.data, enc.len); /* 打印解密数据 */ TRACE_BIN("解密数据", dec.data, dec.len); end: if (ctx) { EVP_CIPHER_CTX_free(ctx); } if (sm4) { EVP_CIPHER_free(sm4); } return 0; }
这里我们单独定义了一个名为 cipher 的函数,cipher 函数中首先定义了密钥 key 和初始化向量 iv,这里我提供几个生成 key 和 iv
的方法:
(1)拍脑门随机想两个对应长度的值;
(2)直接调用随机数生成器 RAND_bytes 生成两个对应的长度的值;
(3)创建 EVP_CIPHER_CTX 后,调用 EVP_CIPHER_CTX_rand_key 接口生成随机密钥, iv 可以采用(1)或者(2)的方式
生成。
我们采用第一种方法… 然后分别调用了 EVP_CipherInit_ex2、EVP_CipherUpdate 和 EVP_CipherFinal_ex 三个接口来完成对称
加密解密操作,这三个接口定义如下:
#include <openssl/evp.h>
/**
* 分组加解密初始化
*
* ctx[in] -- 加密上下文
* type[in] -- 算法实现
* key[in] -- 对称密钥
* iv[in] -- 初始化向量
* enc[in] -- 加密或者解密标志,传 1 为加密,传 0 为解密
* params[in] -- 扩展参数
*
* 调用成功,返回 1,否则返回 0
*/
int EVP_CipherInit_ex2(
EVP_CIPHER_CTX *ctx,
const EVP_CIPHER *type,
const unsigned char *key,
const unsigned char *iv,
int enc,
const OSSL_PARAM params[]
);
/**
* 分组加解密
*
* ctx[in] -- 加密上下文
* out[out] -- 存放加密或者解密结果的内存首地址
* outl[out] -- 结果长度
* in[in] -- 存放原文数据的内存首地址
* inl[in] -- 原文数据长度
*
* 调用成功,返回 1,否则返回 0
*/
int EVP_CipherUpdate(
EVP_CIPHER_CTX *ctx,
unsigned char *out,
int *outl,
const unsigned char *in,
int inl
);
/**
* 加解密收尾
*
* ctx[in] -- 加密上下文对象
* outm[out] -- 存放剩余加密或者解密结果的内存首地址
* outl[out] -- 收尾时处理的数据长度
*
* 调用成功,返回 1,否则返回 0
*/
int EVP_CipherFinal_ex(EVP_CIPHER_CTX *ctx, unsigned char *outm, int *outl);
可以参考之前的示例 2,在 cipher 函数中,我们通过对 EVP_CipherInit_ex2 的 enc 参数传入不同的值来控制程序的逻辑是加密还
是解密,而且对于加解密所需要的密钥和初始化向量(如果算法模式是 ECB 模式则为 NULL)也是在调用该接口传入。
默认情况下,Openssl 会对原文的数据进行 PKCS7 填充以满足加密算法的应用需求。大多数的分组加密算法的块长度都是 8 字节
或者是 16 字节,在加密数据之前,如果原文长度不是分组长度的整数倍,那么就需要用当前缺失的字节数来作为填充内容,填充至原
文末尾,直至原文的长度为加密块长度的整数倍;如果当前原文长度恰好为加密块的整数倍,那么仍然需要在原文后添加一个块长度的
填充数据,以该块长度作为填充内容。为了演示填充操作,我们将示例 2 的程序稍加修改,如下所示 -- 示例 3:
……(同示例2)
int main(int argc, char *argv[])
{
char data[] = "12345678901234567890abcdefgABCDEFGIOPBNM1235678";
int len = sizeof(data);
/* 原文数据 */
struct BIN_DATA in = {
(unsigned char *)data,
len
};
/* 加密缓冲区 */
struct BIN_DATA enc = {
DATA_BUF,
DATA_BUF_LEN / 2
};
/* 解密缓冲区 */
struct BIN_DATA dec = {
DATA_BUF + DATA_BUF_LEN / 2,
DATA_BUF_LEN / 2
};
/* 加密上下文 */
EVP_CIPHER_CTX *ctx = NULL;
/* 获取算法 */
EVP_CIPHER *sm4 = EVP_CIPHER_fetch(NULL, "SM4-CBC", NULL);
if (!sm4) {
return 0;
}
/* 创建加密上下文 */
ctx = EVP_CIPHER_CTX_new();
if (!ctx) {
goto end;
}
/* 加密 */
if (cipher(ctx, sm4, &in, &enc, ENCRYPT) != 1) {
TRACE("加密失败!\n");
goto end;
}
/* 重置上下文 */
if (EVP_CIPHER_CTX_reset(ctx) != 1) {
TRACE("上下文重置失败!\n");
goto end;
}
/* 取消填充 */
EVP_CIPHER_CTX_set_padding(ctx, 0);
/* 解密 */
if (cipher(ctx, sm4, &enc, &dec, DECRYPT) != 1) {
TRACE("解密失败!\n");
goto end;
}
……(以下内容同示例 2)
}
运算结果为:
原文数据 size:48
------------------------+------------------------
31 32 33 34 35 36 37 38 | 39 30 31 32 33 34 35 36
37 38 39 30 61 62 63 64 | 65 66 67 41 42 43 44 45
46 47 49 4f 50 42 4e 4d | 31 32 33 35 36 37 38 00
------------------------+------------------------
加密数据 size:64
------------------------+------------------------
7a 7b 69 88 23 12 70 e7 | f0 d9 48 d9 dc 94 68 cf
01 19 d2 d4 92 3f f6 2f | dc 30 a6 b5 d7 b7 f2 47
87 eb 7b 08 a1 4d c2 f1 | d2 f5 28 c3 d2 2a e1 44
77 16 70 d5 3c ce d6 7f | 20 fb 78 45 b4 ea f8 78
------------------------+------------------------
解密数据 size:64
------------------------+------------------------
31 32 33 34 35 36 37 38 | 39 30 31 32 33 34 35 36
37 38 39 30 61 62 63 64 | 65 66 67 41 42 43 44 45
46 47 49 4f 50 42 4e 4d | 31 32 33 35 36 37 38 00
10 10 10 10 10 10 10 10 | 10 10 10 10 10 10 10 10
------------------------+------------------------
我们在这里使用了名为 EVP_CIPHER_CTX_set_padding 的函数,这个函数的作用是启用和禁用填充。在调用加密原文数据之后,
我们禁用填充,那么解密的时候就会将填充数据打印出来(默认情况下解密时会反填充恢复数据,但是禁用填充功能后则不做任何操作
直接输出解密数据),我们可以看到填充长度等于 SM4加密块长度,均为 16 字节,填充内容也是 0x10。
以上是加密原文恰好是 48 字节的情况,然后我们将 data 的内容改为 "12345678901234567890abcdefgABCDEFGIOPBNM12356
789",包含‘\0’ 的情况下恰好49 个字节,然后运行程序,结果如下:
原文数据 size:49
------------------------+------------------------
31 32 33 34 35 36 37 38 | 39 30 31 32 33 34 35 36
37 38 39 30 61 62 63 64 | 65 66 67 41 42 43 44 45
46 47 49 4f 50 42 4e 4d | 31 32 33 35 36 37 38 39
00
------------------------+------------------------
加密数据 size:64
------------------------+------------------------
7a 7b 69 88 23 12 70 e7 | f0 d9 48 d9 dc 94 68 cf
01 19 d2 d4 92 3f f6 2f | dc 30 a6 b5 d7 b7 f2 47
1e eb b0 4c 16 6a 1f 7b | ca fc dd e8 ed f5 8d 07
52 a9 42 5b de 37 72 70 | d9 57 64 5d 26 15 0e 95
------------------------+------------------------
解密数据 size:64
------------------------+------------------------
31 32 33 34 35 36 37 38 | 39 30 31 32 33 34 35 36
37 38 39 30 61 62 63 64 | 65 66 67 41 42 43 44 45
46 47 49 4f 50 42 4e 4d | 31 32 33 35 36 37 38 39
00 0f 0f 0f 0f 0f 0f 0f | 0f 0f 0f 0f 0f 0f 0f 0f
------------------------+------------------------
我们可以看到 Openssl 自动在原文数据的末尾填充了 15 个 0x0F,恰好是原文长度和分组长度整数倍(4倍 64 字节)的差值。
以上是我们对常用的分组密码算法 API 的讨论,在实际生产环境中,应该根据自己的项目的需求选择不同的分组密码模式,对
我来说一般在加密较小的数据或者密钥时直接采用ECB 模式,简单快捷,加密大一点的文件或者数据时采用 CBC 模式。CFB、OFB
和 CTR 等模式笔者基本没有接触或者使用过,这里不做讨论。
以上就是本章的内容,下一章我们将讨论摘要算法。
参考资料:
1.Openssl 官方文档 -- https://www.openssl.org/docs/
2.Openssl Wiki -- https://wiki.openssl.org/index.php/Main_Page
3.《密码学原理与实践(第三版)》,Douglas R.Stinson 冯登国等译
4.《GB/T 32907-2016 信息安全技术 SM4 分组密码算法》