开源、先进、易用 C 语言加密函数库 Libsodium 中文指南
Libsodium 是一个开源、跨平台、跨语言的加密库,提供了一组简单易用的函数,大大简化了加密、散列、签名、鉴别、解密等复杂工作。支持许多种主流的加密算法和散列算法,包括 AES256-GCM 和 ChaCha20-Poly1305 两种 AEAD 加密方案。此外还提供了一系列方便实用的函数,可完成随机数的生成、大数的计算、编码和解码等辅助性工作。
🧱 起步
执行以下命令,完成 Libsodium 的下载、解压、编译和安装。
1 | yum -y groupinstall "Development Tools" # apt install -y build-essential |
Libsodium 的动态链接库 lib*.so*
位于 /usr/local/lib
目录中。须将此目录设为动态库的搜寻目录之一,否则依赖于 Libsodium 的程序将无法运行。
1 | echo "/usr/local/lib" > /etc/ld.so.conf.d/usr-local-lib.conf |
在 /usr/local/lib/pkgconfig
目录中,可以找到文件 libsodium.pc
。为了能够通过命令 pkg-config
获取编译和链接所需参数,须将此文件复制到 pkg-config
命令的搜寻目录中。
1 | cp /usr/local/lib/pkgconfig/libsodium.pc /usr/share/pkgconfig/ |
通过命令 pkg-config
获取编译和链接所需参数。
1 | pkg-config --cflags libsodium |
如果使用了 make,应当在 Makefile
文件中使用上述两条命令。
1 | CFLAGS = $(pkg-config --cflags libsodium) |
而在程序中,只需包含头文件 sodium.h
即可。
1 |
|
在使用 Libsodium 的其他函数之前,必须先调用函数 sodium_init()
。该函数不需要任何参数,返回 0
表示成功,返回 -1
表示失败,返回 1
则表示已经初始化过了。
📖 知识储备
两种密码体制
当前有两种密码体制:一种称为对称密钥密码体制;另一种称为公钥密码体制。
在对称密钥密码体制中,加密和解密使用相同的密钥。密钥由通信双方事先约定。算法可以公开,而密钥需要保密。
公钥密码体制在加密和解密过程中使用不同的密钥。并且使用其中一个进行加密,则需要用另一个才能解密。这两个成对的密钥在使用时,一个密钥作为私钥,需要保密;另一个密钥作为公钥,可以公开。
对称加密算法
对称加密算法分为分组密码(又称块加密)和序列密码(又称流密码、流加密)两种。
- 著名的分组密码:DES、AES
- 常用的流密码:Salsa20、ChaCha20
MAC 报文鉴别码
MAC 是 Message Authentication Code 的缩写,即报文鉴别码。通常是经过加密的散列值。计算报文鉴别码的算法称为 MAC 算法。常用的 MAC 算法有:
- GMAC
- CBC-MAC
- Poly1305
AE
AE 是 Authenticated encryption 的缩写。顾名思义,这种加密方案不仅能提供机密性,还能提供完整性。任何伪造或篡改都会被发现。
AE 实际上是对称加密算法和 MAC 算法的结合体。在加密一个报文时,需要一个密钥和一个不重数,加密后将得到密文和一个报文鉴别码。报文鉴别码须随同密文一起发送给接收方。
接收方收到报文鉴别码和密文后,须用相同的密钥和不重数才能进行解密。任何对密文和报文鉴别码的篡改都会导致解密失败。当然,只要确保密钥没有泄露,其他人也无法伪造出合法的密文和相应的报文鉴别码。
不重数,即不重复的数,不需要保密,通常是从 0 开始递增的计数器,位数足够多的时候也可以是随机数。密钥和不重数的结合,相当于一次一密,能有效抵御「重放攻击」。
AEAD
AEAD 是 Authenticated Encryption with Additional Data 的缩写。相比于 AE,AEAD 在加、解密时还可以选择性地给定一些没有保密性要求的「附加数据」,例如版本号、时间戳、报文的长度和编码方式等。这些附加数据会参与到报文鉴别码的计算中去,但不会被加密,也不会成为密文的一部分。附加数据可以随同密文一起发送。
常用的 AEAD 有以下两种:
- AES256-GCM
- ChaCha20-Poly1305
Intel 在 2008 年推出新的指令集——AES-NI,为 AES 算法提供了硬件层面上的支持。但在其他平台(ARM)上,针对移动互联网优化的 ChaCha20 的速度大约是 AES 的三倍。
ChaCha20-Poly1305 最初在 2014 年提出,在 2015 年成为 IETF 标准,即 ChaCha20-Poly1305-IETF。后来,又通过对 ChaCha20 的改进,形成 XChaCha20-Poly1305-IETF。这一版本有望成为新的 IETF 标准,也是 Libsodium 目前首推的加密方案。
不同 AEAD 的密钥、不重数和报文鉴别码的长度(单位:位):
AEAD | Key 密钥 | Nonce 不重数 | MAC 报文鉴别码 |
---|---|---|---|
AES256-GCM | 256 | 96 | 128 |
ChaCha20-Poly1305 | 256 | 64 | 128 |
ChaCha20-Poly1305-IETF | 256 | 96 | 128 |
XChaCha20-Poly1305-IETF | 256 | 192 | 128 |
🔐 Libsodium 对 ChaCha20-Poly1305 的支持
Libsodium 为 ChaCha20-Poly1305 的三种版本分别提供了三组函数:
crypto_aead_chacha20poly1305_*()
crypto_aead_chacha20poly1305_ietf_*()
crypto_aead_xchacha20poly1305_ietf_*()
这三组函数在用法上完全一致,因此只要掌握了其中一种,自然也就掌握了其余两种。
加密
1 | int crypto_aead_xchacha20poly1305_ietf_encrypt_detached(unsigned char *c, |
函数 crypto_aead_xchacha20poly1305_ietf_encrypt_detached()
使用密钥 k
和
不重数 npub
对 mlen
字节的报文 m
进行加密,并根据密文和 adlen
字节的附加数据 ad
计算报文鉴别码。密文将被写到 c
,而报文鉴别码将被写到 mac
,maclen
会被设为 mac
的长度。
密文和明文等长。而密钥、不重数、报文鉴别码的长度都是固定的,它们分别等于:
crypto_aead_xchacha20poly1305_ietf_KEYBYTES
crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
crypto_aead_xchacha20poly1305_ietf_ABYTES
若没有关联的数据,则把 ad
设为 NULL
,并把 adlen
设为 0。
此处
nsec
必须始终设为NULL
,下同。
1 |
|
1 | Ciphertext: 5abc40d737 |
函数
sodium_bin2hex()
是 Libsodium 提供的「辅助函数」,具体用法详见下文。
在密钥不变的情况下,不重数必须每次都不一样。建议用 randombytes_buf()
函数产生第一条报文的不重数,再用 sodium_increment()
函数对其进行递增。
解密
解密必须提供相同的密钥 k
、不重数 npub
和附加数据 ad
。
1 | int crypto_aead_xchacha20poly1305_ietf_decrypt_detached(unsigned char *m, |
函数 crypto_aead_xchacha20poly1305_ietf_decrypt_detached()
首先验证 c
中包含的 tag
是否合法。若函数返回 -1
表示验证未通过;若验证通过,则返回 0
,并将解密得到的报文写到 m
。
1 |
|
1 | Message: hello |
合并模式
以上这种将密文和报文鉴别码分开储存的方式称为分开模式。由于大多数需求都是将报文鉴别码直接追加到密文后面,即合并模式。因此,Libsodium 实际上为每种 AEAD 方案都提供两组函数:一组实现分开模式;另一组实现合并模式。
为合并模式设计的函数,相比于分开模式的函数,函数名少了后缀 _detached
。
1 | int crypto_aead_xchacha20poly1305_ietf_encrypt(unsigned char *c, |
密钥、不重数、附加数据、明文等参数的含义同上。在合并模式下,报文鉴别码直接追加到密文后面,因此减少了 mac
和 maclen
两个参数,但参数 c
必须为报文鉴别码预留存储空间。
1 |
|
1 | Ciphertext: 5abc40d737 |
🔑 密钥的派生
在实际应用中,不应从始至终都使用同一个密钥,更不能直接使用密码(通常是简短的字符串)作为密钥,否则很容易遭受「字典攻击」。应当为每次会话专门准备一个子密钥。这就需要一种能够产生大量子密钥的机制。
KDF
KDF 是 Key Derivation Function 的缩写,即密钥派生函数。能够满足上述需求。这类函数通过引入随机数、增加散列迭代次数,增加暴力破解难度。常用的 KDF 有:
- PBKDF2
- Scrypt
- Argon2
Argon2 是最新的算法,也是 Libsodium 首推及其底层默认使用的算法。
基于密码派生密钥
根据给定的密码和一个长度固定的随机数生成指定长度的密钥。
1 | int crypto_pwhash(unsigned char * const out, |
函数 crypto_pwhash()
根据 passwdlen
字节的密码 passwd
和 crypto_pwhash_SALTBYTES
字节的随机数 salt
派生出 outlen
字节的密钥并储存到 out
中。全部参数相同时,生成相同的密钥。
\ | passwdlen |
outlen |
---|---|---|
最小值 | crypto_pwhash_PASSWD_MIN |
crypto_pwhash_BYTES_MIN |
最大值 | crypto_pwhash_PASSWD_MAX |
crypto_pwhash_BYTES_MAX |
倒数两个参数 opslimit
和 memlimit
与性能和内存占用有关,取值如下:
\ | opslimit |
memlimit |
---|---|---|
最小值 | crypto_pwhash_OPSLIMIT_MIN |
crypto_pwhash_MEMLIMIT_MIN |
较快/小 | crypto_pwhash_OPSLIMIT_INTERACTIVE |
crypto_pwhash_MEMLIMIT_INTERACTIVE |
中等 | crypto_pwhash_OPSLIMIT_MODERATE |
crypto_pwhash_MEMLIMIT_MODERATE |
较慢/大 | crypto_pwhash_OPSLIMIT_SENSITIVE |
crypto_pwhash_MEMLIMIT_SENSITIVE |
最大值 | crypto_pwhash_OPSLIMIT_MAX |
crypto_pwhash_MEMLIMIT_MAX |
最后一个参数 alg
决定选用的算法,只有下列 3 种取值可选:
crypto_pwhash_ALG_DEFAULT
Libsodium 推荐的选项。crypto_pwhash_ALG_ARGON2I13
Argon2i 1.3。crypto_pwhash_ALG_ARGON2ID13
Argon2id 1.3。
函数返回 0
表示成功;返回 -1
表示失败(这通常是由于操作系统拒绝分配请求的内存)。
1 |
|
1 | key: a5c2d5ca23026834f7ff177fb8137b62 |
基于主密钥派生子密钥
根据一个主密钥生成多个子密钥。Libsodium 专门为此提供了两个函数 crypto_kdf_*()
。
这两个函数可以根据一个主密钥 key
和一个被称为上下文的参数 ctx
派生出 2^64 个密钥,并且单个子密钥的长度可以在 128(16 字节)到 512 位(64 字节)之间。
1 | void crypto_kdf_keygen(uint8_t key[crypto_kdf_KEYBYTES]); |
函数 crypto_kdf_keygen()
的作用是生成一个主密钥。
1 | int crypto_kdf_derive_from_key(unsigned char *subkey, size_t subkey_len, |
函数 crypto_kdf_derive_from_key()
可以根据主密钥 key
和上下文 ctx
派生出长度为 subkey_len
字节的子密钥。subkey_id
是子密钥的编号,可以是不大于 2^64 - 1
的任意值。
主密钥的长度必须是 crypto_kdf_KEYBYTES
。子密钥的长度 subkey_len
必须介于 crypto_kdf_BYTES_MIN
(含)和 crypto_kdf_BYTES_MAX
(含)之间。
上下文 ctx
是一个 8 字符的字符串,应能描述子密钥的用途。不需要保密,并且强度可以很低。比如 "UserName"
、"__auth__"
、"pictures"
和 "userdata"
等。但其长度必须是 crypto_kdf_CONTEXTBYTES
字节。
使用相同的密钥,但使用不同的 ctx
,就会得到不同的输出。正如其名,ctx
可以和程序的上下文对应。当然,就算一个程序从头到尾只使用一个 ctx
,那也有防止密钥被不同程序重复使用的作用。
1 |
|
1 | subkey1: 0440b65332dc5f6b4a46d262996af08e |
🔩 辅助函数
尽可能使用这些函数,以抵御「时序攻击」。
测试字节序列
sodium_memcmp()
函数 sodium_memcmp()
可完成两个等长字节序列的对比。
1 | int sodium_memcmp(const void * const b1_, const void * const b2_, size_t len); |
如果位于 b1_
的 len
个字节和位于 b2_
的 len
个字节相同,函数返回 0
,否则返回 -1
。
1 | char b1_[6] = "hello"; |
1 | Match |
sodium_is_zero()
函数 sodium_is_zero()
可判断给定的字节序列是否全为 0
。
1 | int sodium_is_zero(const unsigned char *n, const size_t nlen); |
若位于 n
的 nlen
个字节全为 0
,则返回 1
,否则返回 0
。
字节序列的十六进制表示
sodium_bin2hex()
函数 sodium_bin2hex()
可获取字节序列的十六进制表示,并由此得到一个字符串。
1 | char *sodium_bin2hex(char * const hex, const size_t hex_maxlen, |
函数将字符串写到 hex
,这个字符串就是从 bin
开始的 bin_len
个字节的十六进制表示,包括 '\0'
,故 hex_maxlen
至少为 2*bin_len + 1
。该函数始终返回 hex
。
1 | char hex[9]; // 2*4 + 1 = 9 |
1 | 41414141 |
sodium_hex2bin()
函数 sodium_hex2bin()
作用相反,通过解析字节序列的十六进制表示,还原该字节序列。
1 | int sodium_hex2bin(unsigned char * const bin, const size_t bin_maxlen, |
函数将字节序列写到 bin
。bin_maxlen
表示允许写入的最大字节数。而位于 hex
的字符串应当是一个字节序列的十六进制表示,可以没有 '\0'
结尾,需要解析的长度由 hex_len
指定。
ignore
是需要跳过的字符组成的字符串。比如 ": "
表示跳过冒号和空格。此时 "69:FC"
、"69 FC"
、"69 : FC"
和 "69FC"
都视为合法的输入,并产生相同的输出。ignore
可以设为 NULL
,表示不允许任何非法的字符出现。
函数返回 0
表示转换成功,同时 bin_len
会被设为解析得到的字节数;返回 -1
则表示失败。失败的情况有以下两种:
- 解析的结果超过
bin_maxlen
字节; - 遇到非法字符时,如果前面的字符都能顺利解析,函数仍然返回
0
,否则返回-1
。
无论如何 hex_end
总是会被设为下一个待解析的字符的地址。
1 | char bin[5] = {0}; |
1 | 4: abcd, 7 |
Base64 编码/解码
sodium_bin2base64()
函数 sodium_bin2base64()
可获取字节序列的 Base64 编码。
1 | char *sodium_bin2base64(char * const b64, const size_t b64_maxlen, |
Base64 编码有多种变体,采用哪种变体由 variant
指定,有下列 4 种取值可选:
sodium_base64_VARIANT_ORIGINAL
sodium_base64_VARIANT_ORIGINAL_NO_PADDING
sodium_base64_VARIANT_URLSAFE
sodium_base64_VARIANT_URLSAFE_NO_PADDING
这些 Base64 编码并不提供任何形式的加密;就像十六进制编码一样,任何人都可以对它们进行解码。
可以令 b64_maxlen
等于宏 sodium_base64_ENCODED_LEN(BIN_LEN, VARIANT)
,它表示使用 VARIANT
这种变体时,BIN_LEN
个字节的 Base64 编码(包括 '\0'
)的最小长度。
1 | char bin[6] = "hello"; |
1 | 9: aGVsbG8= |
sodium_base642bin()
函数 sodium_base642bin()
可完成 Base64 解码工作。
1 | int sodium_base642bin(unsigned char * const bin, const size_t bin_maxlen, |
返回 -1
表示错误,返回 0
表示解码成功,同时 bin_len
会被设为解码得到的字节数,其他参数的含义参考前文。
1 | size_t bin_len; |
1 | 5: hello |
大数的计算
sodium_increment()
函数 sodium_increment()
用来递增一个任意长度的无符号数。
1 | void sodium_increment(unsigned char *n, const size_t nlen); |
位于 n
的 nlen
字节的数字将按小端字节序处理。加密算法中经常提到的不重数 nonce
就可用此函数进行递增。
1 | unsigned char nonce[8] = {0}; |
1 | 1 |
sodium_add()
函数 sodium_add()
可完成大数的加法。
1 | void sodium_add(unsigned char *a, const unsigned char *b, const size_t len); |
位于 a
和 b
的两个 nlen
字节的加数均按小端字节序的无符号数处理。计算结果将覆盖 a
。
1 | unsigned char a[8] = {1}; |
1 | 1 |
sodium_sub()
函数 sodium_sub()
可完成大数减法。
1 | void sodium_sub(unsigned char *a, const unsigned char *b, const size_t len); |
位于 a
和 b
的两个 nlen
字节的加数均按小端字节序的无符号数处理。计算结果将覆盖 a
。
sodium_compare()
函数 sodium_compare()
可完成两个大数的比较。两个大数均按小端字节序处理。
1 | int sodium_compare(const void * const b1_, const void * const b2_, size_t len); |
返回 0
表示相等,返回 -1
表示 b1_
小于 b2_
;返回 1
表示 b1_
大于 b2_
。