土法炼钢兴趣小组的算法知识备份

密码学工程中最容易犯的 7 个错误

目录

密码学最危险的地方不是算法被破解,而是正确的算法被错误地使用。AES 没有被攻破,但 Debian 两年间生成的每一把 SSH 密钥都能被暴力枚举(种子熵太低);TLS 协议本身安全,但 Apple 一行多余的 goto 让 iOS 的证书验证形同虚设;SHA-1 的理论弱点公布了 12 年,工程师依然在用它签名——直到 Google 真的造出了碰撞。

本文梳理 7 个密码学工程中常见的错误,按从底层到上层的认知递进排列,每个都附有真实 CVE 和修复方案。

7 个错误分层视图

错误 1:使用不安全的随机数生成器

密钥、nonce、IV 的安全性上限就是随机数的质量。下面这段代码看起来”能跑”,但有两个致命问题:

#include <stdlib.h>
#include <time.h>

void generate_key(unsigned char *key, int len) {
    srand(time(NULL));       // 种子:当前秒数,最多 ~30 位熵
    for (int i = 0; i < len; i++)
        key[i] = rand() % 256;  // rand() 不是 CSPRNG
}

time(NULL) 作为种子仅有约 30 位熵(攻击者知道你大概什么时候生成的密钥就能穷举),rand() 是伪随机数生成器(PRNG),不是密码学安全的(CSPRNG)——它的内部状态可以从输出中恢复。rand() 的输出”看起来很随机”,但人眼无法区分统计随机和密码学安全随机,这正是它危险的地方。

CVE-2008-0166:Debian OpenSSL 灾难

2006 年,一个 Debian 维护者在清理 Valgrind 警告时,注释掉了 OpenSSL 随机数生成器中的两行代码

// 被误删的关键代码:
MD_Update(&m, buf, j);      /* purify complains */
    ...
MD_Update(&m, buf, j);      /* 这行被注释掉了 */

维护者做的是正当的代码清理工作,只是不了解被注释掉的那两行对熵池的意义。这导致 PRNG 的种子仅取决于进程 PID——Linux 上 PID 最多 32768 个值。2006 到 2008 年间所有 Debian/Ubuntu 系统生成的 SSH 密钥、SSL 证书、OpenVPN 密钥全部可预测,攻击者只需暴力枚举 32768 个可能的密钥就能破解任何一个。Debian 不得不发布紧急公告,要求所有用户重新生成所有密钥。

正确做法

#include <openssl/rand.h>

void generate_key(unsigned char *key, int len) {
    if (RAND_bytes(key, len) != 1) {
        // 处理错误——熵池不足等极端情况
        abort();
    }
}

或者直接用 Linux 系统调用:

#include <sys/random.h>

void generate_key(unsigned char *key, int len) {
    // getrandom(2) 从内核 CSPRNG 获取随机数
    // 阻塞直到熵池初始化完成,之后永远不阻塞
    if (getrandom(key, len, 0) != len) {
        abort();
    }
}

关于 /dev/urandom vs /dev/random:在 Linux 内核 5.6+ 中,两者使用相同的 CSPRNG 实现。/dev/random 过去会在”熵池耗尽”时阻塞——这在密码学上毫无意义,因为 CSPRNG 一旦获得足够的初始熵就不需要持续注入。简单规则:用 getrandom(2)RAND_bytes(),不要自己实现 PRNG。


错误 2:Nonce / IV 重用

2017 年,Mathy Vanhoef 发现 WPA2 四次握手协议存在设计缺陷(CVE-2017-13077,即 KRACK 攻击):攻击者可以通过重放握手的第三条消息,迫使客户端重新安装已使用过的加密密钥并重置 nonce 计数器。Wi-Fi 流量中 AES-CCMP 的 nonce 被重置,相同 nonce+密钥对被复用,攻击者可以解密数据包甚至注入伪造数据包。Android 和 Linux(wpa_supplicant)受影响最严重,nonce 被重置为全零。影响几乎所有 Wi-Fi 设备。

这不是 AES 的问题,不是 CCMP 的问题——是协议状态机没有正确处理 nonce 生命周期。nonce 重用的根源往往不是”忘了换 IV”,而是协议在异常路径(重传、崩溃恢复、主从切换)下意外重置了计数器。KRACK 就是典型:正常流程下 nonce 永远不会重复,但攻击者诱导了一次状态回退。

Nonce 重用本质上就是一次一密用了两次。下面是一个典型的错误模式:

// "固定 IV,反正每次密钥不一样"——错!
unsigned char iv[12] = {0};  // 全零 IV

void encrypt_message(EVP_CIPHER_CTX *ctx,
                     unsigned char *key,
                     unsigned char *plaintext, int pt_len,
                     unsigned char *ciphertext, int *ct_len) {
    // 同一密钥 + 同一 nonce = 灾难
    EVP_EncryptInit_ex(ctx, EVP_aes_128_gcm(), NULL, key, iv);
    EVP_EncryptUpdate(ctx, ciphertext, ct_len, plaintext, pt_len);
    EVP_EncryptFinal_ex(ctx, ciphertext + *ct_len, ct_len);
}

AES-GCM 的安全性强制要求同一密钥下每个 nonce 只能使用一次。如果 nonce 重复:两段密文的异或等于两段明文的异或(流密码特性),明文信息泄露;更严重的是,GCM 的认证安全性会严重退化——在特定条件下,攻击者可以从重复 nonce 的密文对中推导出足以伪造认证标签的信息。

正确做法

#include <openssl/rand.h>
#include <openssl/evp.h>

int encrypt_aes_gcm(unsigned char *key, int key_len,
                    unsigned char *plaintext, int pt_len,
                    unsigned char *aad, int aad_len,
                    unsigned char *ciphertext,
                    unsigned char *iv_out,    // 输出:随机生成的 IV
                    unsigned char *tag_out) { // 输出:认证标签
    EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();

    // 每次加密生成新的随机 nonce
    if (RAND_bytes(iv_out, 12) != 1) return -1;

    const EVP_CIPHER *cipher = (key_len == 16)
        ? EVP_aes_128_gcm() : EVP_aes_256_gcm();

    EVP_EncryptInit_ex(ctx, cipher, NULL, key, iv_out);

    // 附加认证数据(不加密但参与认证)
    int len;
    if (aad && aad_len > 0)
        EVP_EncryptUpdate(ctx, NULL, &len, aad, aad_len);

    EVP_EncryptUpdate(ctx, ciphertext, &len, plaintext, pt_len);
    int ct_len = len;
    EVP_EncryptFinal_ex(ctx, ciphertext + len, &len);
    ct_len += len;

    // 获取认证标签
    EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, tag_out);
    EVP_CIPHER_CTX_free(ctx);
    return ct_len;
}

几个要点:

随机 96-bit nonce 适合低到中等吞吐的通用场景。如果系统用同一把密钥每秒加密数万条消息(如高吞吐消息队列),随机碰撞概率会变得不可忽略。这类场景更常见的做法是每个密钥维护单调递增计数器,或使用分片前缀(如节点 ID)+ 计数器组合。nonce 的核心要求是唯一性,不是随机性本身。


错误 3:Padding Oracle – 不使用 AEAD

手动组合 cipher + padding + MAC,几乎注定会做错。下面这段 MAC-then-Encrypt 代码就是典型:

// "经典"的 CBC + PKCS#7 + 先 MAC 后加密
void encrypt_and_mac(unsigned char *key_enc, unsigned char *key_mac,
                     unsigned char *iv,
                     unsigned char *plaintext, int pt_len,
                     unsigned char *output) {
    // 1. 计算 HMAC
    unsigned char mac[32];
    HMAC(EVP_sha256(), key_mac, 32, plaintext, pt_len, mac, NULL);

    // 2. 拼接 plaintext || mac
    unsigned char *buf = malloc(pt_len + 32);
    memcpy(buf, plaintext, pt_len);
    memcpy(buf + pt_len, mac, 32);

    // 3. CBC 加密(含 PKCS#7 padding)
    EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
    EVP_EncryptInit_ex(ctx, EVP_aes_256_cbc(), NULL, key_enc, iv);
    int len, ct_len = 0;
    EVP_EncryptUpdate(ctx, output, &len, buf, pt_len + 32);
    ct_len += len;
    EVP_EncryptFinal_ex(ctx, output + ct_len, &len);

    EVP_CIPHER_CTX_free(ctx);
    free(buf);
}

这是 MAC-then-Encrypt 模式——TLS 1.0/1.1 以及 TLS 1.2 中的 CBC 套件长期依赖这种组合(TLS 1.2 虽然也支持 AEAD 套件如 AES-GCM,但大量部署仍在用 CBC)。问题在于:解密时必须先去掉 padding 才能验证 MAC,而 padding 的验证结果(成功还是失败)会通过错误消息或响应时间泄露给攻击者,形成 Padding Oracle。

关于 CBC 模式攻击的深入分析,详见 对 CBC 模式的一些攻击

CVE-2014-3566:POODLE 攻击

POODLE(Padding Oracle On Downgraded Legacy Encryption)利用 SSLv3 中 CBC padding 的不确定性:SSLv3 不要求 padding 字节具有特定值(只检查最后一个字节),攻击者可以逐字节解密 HTTPS 流量。

CVE-2016-2107 同样值得一提:OpenSSL 在修复 Lucky Thirteen 时间侧信道攻击后引入了新的 Padding Oracle——AES-NI 代码路径中的 padding 验证逻辑与非 AES-NI 路径不一致。修一个密码学 bug 引入另一个,这就是手动组合 cipher + MAC 的典型下场。

ECB 模式连 padding 的资格都没有:它对每个块独立加密,相同的明文块产生相同的密文块。经典的“ECB 企鹅”图片说明了这一点:把一张 Linux 企鹅用 ECB 模式加密后,轮廓完全可见。不要用 ECB 加密超过一个块的数据。

正确做法

直接使用 AEAD,加密和认证一步完成,没有 padding:

// AES-256-GCM:加密和认证一步完成,没有 padding
int encrypt_aead(unsigned char *key,
                 unsigned char *plaintext, int pt_len,
                 unsigned char *aad, int aad_len,
                 unsigned char *ciphertext,
                 unsigned char *iv, unsigned char *tag) {
    RAND_bytes(iv, 12);
    EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
    EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, key, iv);
    int len;
    if (aad_len > 0) EVP_EncryptUpdate(ctx, NULL, &len, aad, aad_len);
    EVP_EncryptUpdate(ctx, ciphertext, &len, plaintext, pt_len);
    int ct_len = len;
    EVP_EncryptFinal_ex(ctx, ciphertext + ct_len, &len);
    ct_len += len;
    EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, tag);
    EVP_CIPHER_CTX_free(ctx);
    return ct_len;
}

TLS 1.3 彻底移除了所有非 AEAD 密码套件,只保留了 AES-GCM、AES-CCM 和 ChaCha20-Poly1305。十年 Padding Oracle 攻击史是这个决定的直接背景。

AEAD 套件选择:

场景 推荐 理由
通用加密 AES-256-GCM 硬件加速广泛、NIST 标准
无 AES-NI 的环境 ChaCha20-Poly1305 纯软件性能更优
需要抗 nonce 误用 AES-GCM-SIV nonce 重用不会灾难性崩溃
大文件/流式加密 分段 + 每段独立 nonce 避免单 nonce 超 \(2^{32}\)

很多现存系统在 TLS 1.2 的 CBC 套件上跑了十几年,手动组合 cipher + MAC 的代码往往隐藏在基础设施深处。迁移到 AEAD 是当前性价比最高的密码学升级。


错误 4:时间侧信道

如果程序的执行时间、缓存命中模式或功耗取决于秘密数据的值,攻击者就可以通过观测这些物理量来推断秘密。从最简单的字符串比较到复杂的大数运算,任何依赖秘密数据的分支都可能成为攻击面。而且这类问题在功能测试中完全不可见——代码跑出正确结果,所有 test case 都能过,只有用统计软件测量响应时间才能看到信息在泄露。

例 1:memcmp() 逐字节泄露 HMAC

#include <string.h>

int verify_hmac(unsigned char *expected, unsigned char *received, int len) {
    // memcmp 在发现第一个不同字节时立即返回
    // 攻击者可以通过测量响应时间逐字节猜测正确的 HMAC
    return memcmp(expected, received, len) == 0;
}

memcmp() 是短路比较——第一个字节不同就返回,全部相同才遍历完。攻击者通过精确测量响应时间(微秒甚至纳秒级),可以逐字节确定正确的 HMAC 值。32 字节的 HMAC-SHA256,理论上最多 \(32 \times 256 = 8192\) 次请求就能猜出来,而不是 \(2^{256}\) 次暴力破解。

这种攻击并非理论:2009 年 Nate Lawson 和 Taylor Nelson 在 BlackHat 上演示了对多个 Web 框架的 HMAC 远程时间攻击,包括 Keyczar、Google 的 OpenSocial 以及 OAuth 实现。攻击者通过网络延迟就能逐字节恢复正确的 HMAC——不需要物理接触目标机器。

例 2:缓存侧信道泄露私钥

memcmp 只是最显眼的冰山一角。更深层的问题是:任何依赖秘密数据的分支、查表、内存访问模式都可能被利用。

CVE-2018-0737:OpenSSL 1.1.0h 之前的 RSA 密钥生成过程中,BN_mod_inverse() 的执行时间取决于输入值的具体 bit 模式,攻击者可以通过 cache-timing 侧信道恢复 RSA 私钥。

CVE-2014-0076:OpenSSL 的 ECDSA 签名实现存在 FLUSH+RELOAD 缓存侧信道——攻击者从同一物理主机上的另一个虚拟机,通过观测共享 CPU 缓存行的加载时间,恢复了 ECDSA 签名过程中的 nonce 值,从而重构完整私钥。

这两个 CVE 的共同点:代码逻辑完全正确,没有任何传统意义上的 bug,但执行路径泄露了秘密。

正确做法

对于值比较,使用常量时间函数:

#include <openssl/crypto.h>

int verify_hmac_safe(unsigned char *expected,
                     unsigned char *received, int len) {
    // 常量时间比较:无论哪个字节不同,都遍历所有字节
    return CRYPTO_memcmp(expected, received, len) == 0;
}

CRYPTO_memcmp() 的实现原理:

// 简化版常量时间比较
int constant_time_compare(const unsigned char *a,
                          const unsigned char *b, size_t len) {
    unsigned char result = 0;
    for (size_t i = 0; i < len; i++) {
        result |= a[i] ^ b[i];  // 异或累积差异,不提前返回
    }
    return result;  // 0 表示相同
}

现代编译器的优化可能会破坏常量时间代码。GCC/Clang 可能将上面的循环优化为带有分支的版本。-O0 不现实,volatile 不可靠(编译器仍可能优化周围代码)。实践中应使用经过审计的库函数(CRYPTO_memcmptimingsafe_bcmp),或用内联汇编制造优化屏障:

// GCC/Clang 优化屏障
static inline void memory_barrier(void *p, size_t len) {
    __asm__ __volatile__("" : "+r"(p) : : "memory");
}

大数乘法、模幂、椭圆曲线标量乘等核心密码学运算同样需要常量时间实现。AES 的表查找实现(T-table)也是经典攻击面——现代 CPU 上应使用 AES-NI 指令集或 bitsliced 实现来避免缓存侧信道。


错误 5:不验证证书链

不验证证书的 TLS 连接跟明文没有本质区别。

// "先跑起来再说,验证以后再加"——然后就永远没加
SSL_CTX *ctx = SSL_CTX_new(TLS_client_method());
SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, NULL);  // 不验证服务端证书

或者更危险的——自定义验证回调中 always return 1:

int always_pass(int preverify_ok, X509_STORE_CTX *x509_ctx) {
    return 1;  // 无论证书是否有效一律放行
}
SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, always_pass);

CVE-2014-1266:Apple “goto fail”

2014 年 2 月,Apple 披露了一个影响 iOS 和 macOS 的 TLS 漏洞。漏洞在 SSLVerifySignedServerKeyExchange 函数中:

static OSStatus
SSLVerifySignedServerKeyExchange(SSLContext *ctx, ...)
{
    OSStatus err;
    ...
    if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)
        goto fail;
        goto fail;  // ← 多了一行!无条件跳转到 fail
    if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0)
        goto fail;

    err = sslRawVerify(ctx, ...);  // 这行永远不会执行
    ...
fail:
    ...
    return err;
}

第二个 goto fail 是无条件的——它跳过了实际的签名验证步骤。由于 err 此时仍保持 SSLHashSHA1.update() 的成功返回值(0),函数返回”验证通过”。

任何人都可以对 iOS/macOS 设备发起中间人攻击,客户端会欣然接受伪造的服务器证书。这个 bug 影响了数亿设备,起因是代码合并时一行重复的 goto

正确做法

SSL_CTX *ctx = SSL_CTX_new(TLS_client_method());

// 1. 加载系统根证书
SSL_CTX_set_default_verify_paths(ctx);

// 2. 强制验证对端证书
SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL);

// 3. 设置验证深度(证书链最大长度)
SSL_CTX_set_verify_depth(ctx, 4);

// 连接后验证 hostname
SSL *ssl = SSL_new(ctx);
SSL_set_tlsext_host_name(ssl, "example.com");  // SNI
SSL_set1_host(ssl, "example.com");             // hostname 验证

// SSL_connect() 之后检查验证结果
if (SSL_get_verify_result(ssl) != X509_V_OK) {
    // 证书验证失败,拒绝连接
    SSL_shutdown(ssl);
    // ...
}

要点:

  1. SSL_VERIFY_PEER 必须设置,否则不验证对端证书
  2. hostname 验证:证书有效不等于“是你要连接的那个服务器”,必须额外检查 CN/SAN 与目标域名匹配
  3. 不要自定义验证回调,除非完全理解 X.509 验证的每一步
  4. 不要在出错时 fallback 到不验证——这是 POODLE 等降级攻击的根源

开发环境用自签名证书时,SSL_VERIFY_NONE 是最快的通关方式,然后这行代码就随着业务上线了——它不会产生任何错误日志。Apple goto fail 的根因也不是安全工程师的失误,而是一次普通的代码合并。

更多关于 TLS 1.3 如何从协议层面防止此类问题的讨论,参见 不到 500 行 C 实现 TLS 1.3 握手


错误 6:使用已被破解的哈希算法

MD5 的碰撞攻击在 2004 年就被实现,SHA-1 在 2017 年被实际碰撞。下图是攻击时间线:

SHA-1 / MD5 攻击时间线

但到今天仍然能在生产代码中找到这两个算法用于安全用途:

#include <openssl/md5.h>
#include <openssl/sha.h>

// 用 MD5 做文件完整性校验 -- 错!
void verify_download(unsigned char *data, size_t len,
                     unsigned char *expected_hash) {
    unsigned char hash[MD5_DIGEST_LENGTH];
    MD5(data, len, hash);
    if (memcmp(hash, expected_hash, MD5_DIGEST_LENGTH) == 0)
        printf("File OK\n");  // 攻击者可以构造碰撞
}

// 用 SHA-1 签名 -- 同样不行
void sign_document(unsigned char *doc, size_t len, ...) {
    unsigned char hash[SHA_DIGEST_LENGTH];
    SHA1(doc, len, hash);
    // ... 用私钥签名 hash ...
    // 攻击者可以构造另一个文档具有相同的 SHA-1
}

SHAttered(2017)

2017 年 2 月,Google 和 CWI Amsterdam 联合发布了 SHAttered 攻击:他们构造了两个内容完全不同的 PDF 文件,但 SHA-1 哈希值完全相同。这需要约 \(2^{63}\) 次 SHA-1 计算(等价于 6500 年单 CPU 或 110 GPU 年的算力),成本约 11 万美元——对于国家级攻击者来说微不足道。

更早的 CVE-2009-2409:研究人员利用 MD5 碰撞伪造了一个受信任的 CA 证书,可以为任意域名签发看似合法的 SSL 证书。这意味着所有使用 MD5 签名的 CA 基础设施都处于风险中。

2012 年 LinkedIn 泄露了 650 万用户的密码哈希,使用的是无盐 SHA-1,攻击者在几天内就破解了大部分密码。

正确做法

#include <openssl/evp.h>

// 文件完整性 / 数字签名:使用 SHA-256 或 SHA-3
int hash_sha256(unsigned char *data, size_t len,
                unsigned char *out) {
    EVP_MD_CTX *ctx = EVP_MD_CTX_new();
    EVP_DigestInit_ex(ctx, EVP_sha256(), NULL);
    EVP_DigestUpdate(ctx, data, len);
    unsigned int md_len;
    EVP_DigestFinal_ex(ctx, out, &md_len);
    EVP_MD_CTX_free(ctx);
    return md_len;  // 32 bytes
}

密码存储是另一回事:即使 SHA-256 也不适合直接存储密码——它太快了,攻击者可以每秒尝试数十亿次。密码存储必须使用专门的慢哈希函数,详见 密码怎么存?

用途 推荐算法 不要用
数字签名 SHA-256, SHA-384, SHA-3 MD5, SHA-1
文件完整性 SHA-256, BLAKE2b MD5, SHA-1
密码存储 Argon2id, bcrypt, scrypt MD5, SHA-1, SHA-256
HMAC HMAC-SHA-256 HMAC-MD5

从 2005 年 SHA-1 被理论破解到 2017 年被实际碰撞,中间隔了 12 年。“还没人真的碰撞出来呢”是这 12 年里反对迁移的主要理由。当密码学家说“这个算法不安全了”的时候,迁移窗口就已经打开了。

补充一点:上面说的“不要用”针对的是对抗性场景——下载校验、数字签名、证书验证、供应链完整性等攻击者可以主动构造碰撞的场合。如果只是用 MD5 做数据去重或缓存键(非安全用途,不面对攻击者),风险模型不同。但经验是:一旦代码里引入了 MD5(),总会有人在安全敏感的路径上顺手调用它。


错误 7:密钥硬编码与泄露

代码里写死的密钥不是秘密,它是公开信息。

// "部署之前再改"——然后就忘了
static const char *AES_KEY = "MyS3cretK3y!2024";
static const char *API_SECRET = "sk_live_4eC39HqLyjWDarjtT1zdp7dc";

void encrypt_user_data(unsigned char *data, int len, ...) {
    // 用硬编码密钥加密
    EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), NULL,
                       (unsigned char *)AES_KEY, iv);
    // ...
}

这个密钥会出现在:

CVE-2015-7755:Juniper ScreenOS 后门

2015 年 12 月,Juniper Networks 在其 ScreenOS 防火墙固件中发现了未经授权的后门代码。其中一个后门使用硬编码密码 <<< %s(un='%s') = %u,任何知道这个字符串的人都可以通过 SSH/Telnet 以管理员身份登录任何受影响的 Juniper 防火墙。这段后门代码从 2012 年就存在,直到 2015 年安全审计时才被发现。

另一个案例是 CVE-2021-22005:VMware vCenter Server 中的硬编码密钥被用于签名会话 cookie,攻击者利用这个密钥伪造 cookie 实现了远程代码执行。

GitHub 的 secret scanning 功能在 2023 年检测到超过 100 万个泄露的 API 密钥,包括 AWS 凭证、Stripe API key、数据库密码等。一旦推送到公开仓库,机器人在几秒钟内就能扫描到并利用。

正确做法

#include <stdlib.h>

int load_key_from_env(unsigned char *key, int key_len) {
    const char *key_hex = getenv("APP_ENCRYPTION_KEY");
    if (!key_hex) {
        fprintf(stderr, "APP_ENCRYPTION_KEY not set\n");
        return -1;
    }
    // 从十六进制字符串解码到二进制密钥
    if (strlen(key_hex) != (size_t)(key_len * 2)) return -1;
    for (int i = 0; i < key_len; i++) {
        sscanf(key_hex + 2 * i, "%2hhx", &key[i]);
    }
    return 0;
}

密钥管理分级:

级别 方案 适用场景
能跑 环境变量 本地开发、个人项目
合格 受限权限文件 / sidecar 注入(如 Kubernetes Secrets + 挂载卷) 内部服务、非核心系统
生产推荐 KMS(AWS KMS / GCP Cloud KMS)/ HashiCorp Vault / HSM + 自动轮换 + 审计日志 所有面向用户的系统

环境变量不是密钥管理系统,它只是密钥传递渠道。环境变量会出现在 /proc/<pid>/environ、容器 inspect 输出、进程崩溃转储和误打的日志里。生产环境不要把环境变量当成终点。

密钥管理原则:

  1. 密钥不入代码:代码和配置文件里不要出现密钥明文
  2. 密钥轮换:定期更换密钥,限制单个密钥的使用寿命和加密数据量
  3. 最小权限:应用只能访问它需要的密钥,密钥服务器设置访问控制
  4. 审计:记录每次密钥访问,异常访问模式触发告警
  5. Git 防线:在 CI/CD 中集成 git-secretstruffleHog,在 pre-commit 阶段拦截密钥泄露

密钥管理的“正确做法”需要基础设施投入,在项目早期很容易被视为过度工程。于是密钥先写在代码里、写在 .env 里,等项目上线后这行 const char *key = ... 就永远留在了 Git 历史里。


检查清单

如果你在写涉及密码学的代码,每次提交前过一遍这个清单:

# 检查项 如果不确定
1 随机数来自 RAND_bytes()getrandom(2) 永远不要用 rand()time() 做种子
2 每次加密使用新的随机 nonce/IV 检查 nonce 是否有可能重复
3 使用 AEAD(AES-GCM / ChaCha20-Poly1305) 不要手动组合 cipher + MAC
4 密码学数据比较用 CRYPTO_memcmp() 搜索代码中所有 memcmp 调用
5 TLS 连接验证了证书链和 hostname 搜索 SSL_VERIFY_NONE
6 没有用 MD5/SHA-1 做签名或完整性验证 搜索 MD5(SHA1(
7 密钥不在代码/配置文件/Git 中 运行 truffleHog 扫描

如果只做一件事:把加密方案升级到 AEAD + 系统 CSPRNG,这一步就能消除错误 1、2、3。


延伸阅读

站内文章:

外部资源:


By .